diff --git a/.zuul.yaml b/.zuul.yaml index a29e66527..a250a8526 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -20,8 +20,12 @@ - openstack-tox-py311: required-projects: - openstack/neutron + - openstack-tox-docs: + required-projects: + - openstack/neutron - neutron-vpnaas-functional-sswan - neutron-tempest-plugin-vpnaas + - neutron-tempest-plugin-vpnaas-ovn - neutron-tempest-plugin-vpnaas-libreswan-centos: # TODO(mlavalle) switch to voting when this job is moved to Centos # 8 @@ -40,8 +44,12 @@ - openstack-tox-py311: required-projects: - openstack/neutron + - openstack-tox-docs: + required-projects: + - openstack/neutron - neutron-vpnaas-functional-sswan - neutron-tempest-plugin-vpnaas + - neutron-tempest-plugin-vpnaas-ovn # TODO(mlavalle) uncomment following line when the job is moved to # Centos 8 # - neutron-tempest-plugin-vpnaas-libreswan-centos @@ -62,6 +70,7 @@ - openstack/neutron - neutron-vpnaas-openstack-tox-py310-with-sqlalchemy-main - neutron-tempest-plugin-vpnaas + - neutron-tempest-plugin-vpnaas-ovn - neutron-vpnaas-functional-sswan - job: diff --git a/devstack/ovn-local.conf.sample b/devstack/ovn-local.conf.sample new file mode 100644 index 000000000..df03ce662 --- /dev/null +++ b/devstack/ovn-local.conf.sample @@ -0,0 +1,44 @@ +[[local|localrc]] +DATABASE_PASSWORD=password +RABBIT_PASSWORD=password +SERVICE_PASSWORD=password +SERVICE_TOKEN=password +ADMIN_PASSWORD=password + +Q_AGENT=ovn +Q_ML2_PLUGIN_MECHANISM_DRIVERS=ovn,logger +Q_ML2_PLUGIN_TYPE_DRIVERS=local,flat,vlan,geneve +Q_ML2_TENANT_NETWORK_TYPE=geneve + +LOGFILE="/opt/stack/logs/devstacklog.txt" + +enable_service ovn-northd +enable_service ovn-controller +enable_service q-ovn-metadata-agent +enable_service q-ovn-vpn-agent +enable_service q-svc +enable_service q-log + +# Disable Neutron agents not used with OVN. +disable_service q-agt +disable_service q-l3 +disable_service q-dhcp +disable_service q-meta + +enable_plugin neutron https://opendev.org/openstack/neutron +enable_plugin neutron-tempest-plugin https://opendev.org/openstack/neutron-tempest-plugin.git +enable_plugin neutron-vpnaas https://opendev.org/openstack/neutron-vpnaas.git + +# Horizon (the web UI) is enabled by default. You may want to disable +# it here to speed up DevStack a bit. +enable_service horizon + +# disable_service cinder c-sch c-api c-vol c-bak + +#new +# OVN_BUILD_MODULES=True +#new +# ENABLE_CHASSIS_AS_GW=True + +# IPsec driver to use. Optional, defaults to strongswan. +IPSEC_PACKAGE="strongswan" diff --git a/devstack/plugin.sh b/devstack/plugin.sh index 068a90afb..8451ad1c2 100644 --- a/devstack/plugin.sh +++ b/devstack/plugin.sh @@ -9,9 +9,14 @@ source $LIBDIR/l3_agent NEUTRON_L3_CONF=${NEUTRON_L3_CONF:-$Q_L3_CONF_FILE} +function is_ovn_enabled { + [[ $Q_AGENT == "ovn" ]] && return 0 + return 1 +} + function neutron_vpnaas_install { setup_develop $NEUTRON_VPNAAS_DIR - if is_service_enabled q-l3 neutron-l3; then + if is_service_enabled q-l3 neutron-l3 q-ovn-vpn-agent; then neutron_agent_vpnaas_install_agent_packages fi } @@ -49,6 +54,43 @@ function neutron_vpnaas_configure_agent { fi } +function neutron_vpnaas_configure_ovn_agent { + cp $NEUTRON_VPNAAS_DIR/etc/neutron_ovn_vpn_agent.ini.sample $OVN_VPNAGENT_CONF + + iniset $OVN_VPNAGENT_CONF DEFAULT interface_driver openvswitch + iniset $OVN_VPNAGENT_CONF DEFAULT state_path $DATA_DIR/neutron + iniset_rpc_backend neutron-vpnaas $OVN_VPNAGENT_CONF + iniset $OVN_VPNAGENT_CONF agent root_helper "$Q_RR_COMMAND" + if [[ "$Q_USE_ROOTWRAP_DAEMON" == "True" ]]; then + iniset $OVN_VPNAGENT_CONF agent root_helper_daemon "$Q_RR_DAEMON_COMMAND" + fi + + if [[ "$IPSEC_PACKAGE" == "strongswan" ]]; then + iniset_multiline $OVN_VPNAGENT_CONF vpnagent vpn_device_driver neutron_vpnaas.services.vpn.device_drivers.ovn_ipsec.OvnStrongSwanDriver + elif [[ "$IPSEC_PACKAGE" == "libreswan" ]]; then + iniset_multiline $OVN_VPNAGENT_CONF vpnagent vpn_device_driver neutron_vpnaas.services.vpn.device_drivers.ovn_ipsec.OvnLibreSwanDriver + else + iniset_multiline $OVN_VPNAGENT_CONF vpnagent vpn_device_driver $NEUTRON_VPNAAS_DEVICE_DRIVER + fi + + OVSDB_SERVER_LOCAL_HOST=$SERVICE_LOCAL_HOST + if [[ "$SERVICE_IP_VERSION" == 6 ]]; then + OVSDB_SERVER_LOCAL_HOST=[$OVSDB_SERVER_LOCAL_HOST] + fi + OVN_SB_REMOTE=${OVN_SB_REMOTE:-$OVN_PROTO:$SERVICE_HOST:6642} + + iniset $OVN_VPNAGENT_CONF ovs ovsdb_connection tcp:$OVSDB_SERVER_LOCAL_HOST:6640 + iniset $OVN_VPNAGENT_CONF ovn ovn_sb_connection $OVN_SB_REMOTE + if is_service_enabled tls-proxy; then + iniset $OVN_VPNAGENT_CONF ovn \ + ovn_sb_ca_cert $INT_CA_DIR/ca-chain.pem + iniset $OVN_VPNAGENT_CONF ovn \ + ovn_sb_certificate $INT_CA_DIR/$DEVSTACK_CERT_NAME.crt + iniset $OVN_VPNAGENT_CONF ovn \ + ovn_sb_private_key $INT_CA_DIR/private/$DEVSTACK_CERT_NAME.key + fi +} + function neutron_vpnaas_configure_db { $NEUTRON_BIN_DIR/neutron-db-manage --subproject neutron-vpnaas --config-file $NEUTRON_CONF upgrade head } @@ -58,6 +100,15 @@ function neutron_vpnaas_generate_config_files { (cd $NEUTRON_VPNAAS_DIR && exec ./tools/generate_config_file_samples.sh) } +function neutron_vpnaas_start_vpnagent { + NEUTRON_OVN_BIN_DIR=$(get_python_exec_prefix) + NEUTRON_OVN_VPNAGENT_BINARY="neutron-ovn-vpn-agent" + + run_process q-ovn-vpn-agent "$NEUTRON_OVN_BIN_DIR/$NEUTRON_OVN_VPNAGENT_BINARY --config-file $OVN_VPNAGENT_CONF" + # Format logging + setup_logging $OVN_VPNAGENT_CONF +} + # Main plugin processing # NOP for pre-install step @@ -77,6 +128,15 @@ elif [[ "$1" == "stack" && "$2" == "post-config" ]]; then echo_summary "Configuring neutron-vpnaas agent" neutron_vpnaas_configure_agent fi + if is_service_enabled q-ovn-vpn-agent && is_ovn_enabled; then + echo_summary "Configuring neutron-ovn-vpn-agent" + neutron_vpnaas_configure_ovn_agent + fi + +elif [[ "$1" == "stack" && "$2" == "extra" ]]; then + if is_service_enabled q-ovn-vpn-agent && is_ovn_enabled; then + neutron_vpnaas_start_vpnagent + fi # NOP for clean step diff --git a/devstack/settings b/devstack/settings index 0f16be035..a75f90e07 100644 --- a/devstack/settings +++ b/devstack/settings @@ -1,23 +1,36 @@ # Settings for the VPNaaS devstack plugin # Plugin -VPN_PLUGIN=${VPN_PLUGIN:-"vpnaas"} +if [[ $Q_AGENT == "ovn" ]]; then + VPN_PLUGIN=${VPN_PLUGIN:-"ovn-vpnaas"} +else + VPN_PLUGIN=${VPN_PLUGIN:-"vpnaas"} +fi # Device driver IPSEC_PACKAGE=${IPSEC_PACKAGE:-"strongswan"} -NEUTRON_VPNAAS_DEVICE_DRIVER=${NEUTRON_VPNAAS_DEVICE_DRIVER:-"neutron_vpnaas.services.vpn.device_drivers.strongswan_ipsec:StrongSwanDriver"} +if [[ $Q_AGENT == "ovn" ]]; then + NEUTRON_VPNAAS_DEVICE_DRIVER=${NEUTRON_VPNAAS_DEVICE_DRIVER:-"neutron_vpnaas.services.vpn.device_drivers.ovn_ipsec.OvnStrongSwanDriver"} +else + NEUTRON_VPNAAS_DEVICE_DRIVER=${NEUTRON_VPNAAS_DEVICE_DRIVER:-"neutron_vpnaas.services.vpn.device_drivers.strongswan_ipsec:StrongSwanDriver"} +fi function _get_service_provider { - local ipsec_package=$1 - local name driver + local ipsec_package=$1 + local name driver - driver="neutron_vpnaas.services.vpn.service_drivers.ipsec.IPsecVPNDriver" - if [ "$ipsec_package" = "libreswan" ]; then - name="openswan" - else - name="strongswan" - fi - echo "VPN:${name}:${driver}:default" + if [[ $Q_AGENT == "ovn" ]]; then + driver="neutron_vpnaas.services.vpn.service_drivers.ovn_ipsec.IPsecOvnVPNDriver" + else + driver="neutron_vpnaas.services.vpn.service_drivers.ipsec.IPsecVPNDriver" + fi + + if [ "$ipsec_package" = "libreswan" ]; then + name="openswan" + else + name="strongswan" + fi + echo "VPN:${name}:${driver}:default" } # Service Driver, default value depends on IPSEC_PACKAGE. @@ -31,3 +44,5 @@ NEUTRON_VPNAAS_DIR=$DEST/neutron-vpnaas NEUTRON_VPNAAS_CONF_FILE=neutron_vpnaas.conf NEUTRON_VPNAAS_CONF=$NEUTRON_CONF_DIR/$NEUTRON_VPNAAS_CONF_FILE + +OVN_VPNAGENT_CONF=$NEUTRON_CONF_DIR/neutron_ovn_vpn_agent.ini diff --git a/doc/source/configuration/index.rst b/doc/source/configuration/index.rst index bd27ee90b..605a0dbb2 100644 --- a/doc/source/configuration/index.rst +++ b/doc/source/configuration/index.rst @@ -15,6 +15,7 @@ Neutron VPNaaS uses the following configuration files for its various services. neutron_vpnaas l3_agent + neutron_ovn_vpn_agent The following are sample configuration files for Neutron VPNaaS services and utilities. These are generated from code and reflect the current state of code diff --git a/doc/source/configuration/neutron_ovn_vpn_agent.rst b/doc/source/configuration/neutron_ovn_vpn_agent.rst new file mode 100644 index 000000000..15fd6de5f --- /dev/null +++ b/doc/source/configuration/neutron_ovn_vpn_agent.rst @@ -0,0 +1,8 @@ +========================= +neutron_ovn_vpn_agent.ini +========================= + +This is a configuration file for the OVN VPN agent. + +.. show-options:: + :config-file: etc/oslo-config-generator/neutron_ovn_vpn_agent.ini diff --git a/etc/neutron/rootwrap.d/vpnaas.filters b/etc/neutron/rootwrap.d/vpnaas.filters index 846ac2d1c..7d2615333 100644 --- a/etc/neutron/rootwrap.d/vpnaas.filters +++ b/etc/neutron/rootwrap.d/vpnaas.filters @@ -12,6 +12,8 @@ cp: RegExpFilter, cp, root, cp, -a, .*, .*/strongswan.d ip: IpFilter, ip, root ip_exec: IpNetnsExecFilter, ip, root ipsec: CommandFilter, ipsec, root +sysctl_ip4_forward: RegExpFilter, sysctl, root, sysctl, -w, net.ipv4.ip_forward=1 +sysctl_ip6_forward: RegExpFilter, sysctl, root, sysctl, -w, net.ipv6.conf.all.forwarding=1 rm: RegExpFilter, rm, root, rm, -rf, (.*/strongswan.d|.*/ipsec/[0-9a-z-]+) rm_file: RegExpFilter, rm, root, rm, -f, .*/ipsec.secrets strongswan: CommandFilter, strongswan, root diff --git a/etc/oslo-config-generator/neutron_ovn_vpn_agent.ini b/etc/oslo-config-generator/neutron_ovn_vpn_agent.ini new file mode 100644 index 000000000..e4a3e1721 --- /dev/null +++ b/etc/oslo-config-generator/neutron_ovn_vpn_agent.ini @@ -0,0 +1,5 @@ +[DEFAULT] +output_file = etc/neutron_ovn_vpn_agent.ini.sample +wrap_width = 79 + +namespace = neutron.vpnaas.ovn_agent diff --git a/neutron_vpnaas/agent/__init__.py b/neutron_vpnaas/agent/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/neutron_vpnaas/agent/ovn/__init__.py b/neutron_vpnaas/agent/ovn/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/neutron_vpnaas/agent/ovn/vpn/__init__.py b/neutron_vpnaas/agent/ovn/vpn/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/neutron_vpnaas/agent/ovn/vpn/agent.py b/neutron_vpnaas/agent/ovn/vpn/agent.py new file mode 100644 index 000000000..7374fb220 --- /dev/null +++ b/neutron_vpnaas/agent/ovn/vpn/agent.py @@ -0,0 +1,167 @@ +# Copyright 2017 Red Hat, Inc. +# Copyright 2023 SysEleven GmbH +# +# 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 uuid + +from neutron.agent.linux import external_process +from neutron.common.ovn import utils as ovn_utils +from neutron.conf.plugins.ml2.drivers.ovn import ovn_conf as config +from oslo_log import log as logging +from oslo_service import service +from ovsdbapp.backend.ovs_idl import event as row_event +from ovsdbapp.backend.ovs_idl import vlog + +from neutron_vpnaas.agent.ovn.vpn import ovsdb +from neutron_vpnaas.services.vpn.common import constants +from neutron_vpnaas.services.vpn import vpn_service + +LOG = logging.getLogger(__name__) + +OVN_VPNAGENT_UUID_NAMESPACE = uuid.UUID('e1ce3b12-b1e0-4c81-ba27-07c0fec9c12b') + + +class ChassisCreateEventBase(row_event.RowEvent): + """Row create event - Chassis name == our_chassis. + + On connection, we get a dump of all chassis so if we catch a creation + of our own chassis it has to be a reconnection. In this case, we need + to do a full sync to make sure that we capture all changes while the + connection to OVSDB was down. + """ + table = None + + def __init__(self, vpn_agent): + self.agent = vpn_agent + self.first_time = True + events = (self.ROW_CREATE,) + super().__init__( + events, self.table, (('name', '=', self.agent.chassis),)) + self.event_name = self.__class__.__name__ + + def run(self, event, row, old): + if self.first_time: + self.first_time = False + else: + # NOTE(lucasagomes): Re-register the ovn vpn agent + # with the local chassis in case its entry was re-created + # (happens when restarting the ovn-controller) + self.agent.register_vpn_agent() + LOG.info("Connection to OVSDB established, doing a full sync") + self.agent.sync() + + +class ChassisCreateEvent(ChassisCreateEventBase): + table = 'Chassis' + + +class ChassisPrivateCreateEvent(ChassisCreateEventBase): + table = 'Chassis_Private' + + +class SbGlobalUpdateEvent(row_event.RowEvent): + """Row update event on SB_Global table.""" + + def __init__(self, vpn_agent): + self.agent = vpn_agent + table = 'SB_Global' + events = (self.ROW_UPDATE,) + super().__init__(events, table, None) + self.event_name = self.__class__.__name__ + + def run(self, event, row, old): + table = ('Chassis_Private' if self.agent.has_chassis_private + else 'Chassis') + external_ids = {constants.OVN_AGENT_VPN_SB_CFG_KEY: str(row.nb_cfg)} + self.agent.sb_idl.db_set( + table, self.agent.chassis, + ('external_ids', external_ids)).execute() + + +class OvnVpnAgent(service.Service): + def __init__(self, conf): + super().__init__() + self.conf = conf + vlog.use_python_logger(max_level=config.get_ovn_ovsdb_log_level()) + self._process_monitor = external_process.ProcessMonitor( + config=self.conf, + resource_type='ipsec') + + self.service = vpn_service.VPNService(self) + self.device_drivers = self.service.load_device_drivers(self.conf.host) + + def _load_config(self): + self.chassis = self._get_own_chassis_name() + try: + self.chassis_id = uuid.UUID(self.chassis) + except ValueError: + # OVS system-id could be a non UUID formatted string. + self.chassis_id = uuid.uuid5(OVN_VPNAGENT_UUID_NAMESPACE, + self.chassis) + LOG.debug("Loaded chassis name %s (UUID: %s).", + self.chassis, self.chassis_id) + + def start(self): + super().start() + + self.ovs_idl = ovsdb.VPNAgentOvsIdl().start() + self._load_config() + + tables = ('SB_Global', 'Chassis') + events = (SbGlobalUpdateEvent(self), ) + # TODO(lucasagomes): Remove this in the future. Try to register + # the Chassis_Private table, if not present, fallback to the normal + # Chassis table. + # Open the connection to OVN SB database. + self.has_chassis_private = False + try: + self.sb_idl = ovsdb.VPNAgentOvnSbIdl( + chassis=self.chassis, tables=tables + ('Chassis_Private', ), + events=events + (ChassisPrivateCreateEvent(self), )).start() + self.has_chassis_private = True + except AssertionError: + self.sb_idl = ovsdb.VPNAgentOvnSbIdl( + chassis=self.chassis, tables=tables, + events=events + (ChassisCreateEvent(self), )).start() + + # Register the agent with its corresponding Chassis + self.register_vpn_agent() + + # Do the initial sync. + self.sync() + + def sync(self): + for driver in self.device_drivers: + driver.sync(driver.context, []) + + @ovn_utils.retry() + def register_vpn_agent(self): + # NOTE(lucasagomes): db_add() will not overwrite the UUID if + # it's already set. + table = ('Chassis_Private' if self.has_chassis_private else 'Chassis') + # Generate unique, but consistent vpn agent id for chassis name + agent_id = uuid.uuid5(self.chassis_id, 'vpn_agent') + ext_ids = {constants.OVN_AGENT_VPN_ID_KEY: str(agent_id)} + self.sb_idl.db_add(table, self.chassis, 'external_ids', + ext_ids).execute(check_error=True) + + def _get_own_chassis_name(self): + """Return the external_ids:system-id value of the Open_vSwitch table. + + As long as ovn-controller is running on this node, the key is + guaranteed to exist and will include the chassis name. + """ + ext_ids = self.ovs_idl.db_get( + 'Open_vSwitch', '.', 'external_ids').execute() + return ext_ids['system-id'] diff --git a/neutron_vpnaas/agent/ovn/vpn/ovsdb.py b/neutron_vpnaas/agent/ovn/vpn/ovsdb.py new file mode 100644 index 000000000..765865769 --- /dev/null +++ b/neutron_vpnaas/agent/ovn/vpn/ovsdb.py @@ -0,0 +1,80 @@ +# Copyright 2017 Red Hat, Inc. +# Copyright 2023 SysEleven GmbH +# +# 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 neutron.conf.plugins.ml2.drivers.ovn import ovn_conf as config +from neutron.plugins.ml2.drivers.ovn.mech_driver.ovsdb import impl_idl_ovn +from neutron.plugins.ml2.drivers.ovn.mech_driver.ovsdb import ovsdb_monitor +from oslo_log import log as logging +from ovs.db import idl +from ovsdbapp.backend.ovs_idl import connection +from ovsdbapp.backend.ovs_idl import idlutils +from ovsdbapp.schema.open_vswitch import impl_idl as idl_ovs +import tenacity + +LOG = logging.getLogger(__name__) + + +class VPNAgentOvnSbIdl(ovsdb_monitor.OvnIdl): + + SCHEMA = 'OVN_Southbound' + + def __init__(self, chassis=None, events=None, tables=None): + connection_string = config.get_ovn_sb_connection() + ovsdb_monitor._check_and_set_ssl_files(self.SCHEMA) + helper = self._get_ovsdb_helper(connection_string) + if tables is None: + tables = ('Chassis', 'SB_Global') + for table in tables: + helper.register_table(table) + try: + super().__init__( + None, connection_string, helper, leader_only=False) + except TypeError: + # TODO(bpetermann) We can remove this when we require ovs>=2.12.0 + super().__init__(None, connection_string, helper) + if chassis: + table = ('Chassis_Private' if 'Chassis_Private' in tables + else 'Chassis') + self.set_table_condition(table, [['name', '==', chassis]]) + if events: + self.notify_handler.watch_events(events) + + @tenacity.retry( + wait=tenacity.wait_exponential(max=180), + reraise=True) + def _get_ovsdb_helper(self, connection_string): + return idlutils.get_schema_helper(connection_string, self.SCHEMA) + + def start(self): + conn = connection.Connection( + self, timeout=config.get_ovn_ovsdb_timeout()) + return impl_idl_ovn.OvsdbSbOvnIdl(conn) + + +class VPNAgentOvsIdl(object): + + def start(self): + connection_string = config.cfg.CONF.ovs.ovsdb_connection + helper = idlutils.get_schema_helper(connection_string, + 'Open_vSwitch') + tables = ('Open_vSwitch', 'Bridge', 'Port', 'Interface') + for table in tables: + helper.register_table(table) + ovs_idl = idl.Idl( + connection_string, helper, + probe_interval=config.get_ovn_ovsdb_probe_interval()) + conn = connection.Connection( + ovs_idl, timeout=config.cfg.CONF.ovs.ovsdb_connection_timeout) + return idl_ovs.OvsdbIdl(conn) diff --git a/neutron_vpnaas/api/rpc/agentnotifiers/vpn_rpc_agent_api.py b/neutron_vpnaas/api/rpc/agentnotifiers/vpn_rpc_agent_api.py new file mode 100644 index 000000000..3e6fefd47 --- /dev/null +++ b/neutron_vpnaas/api/rpc/agentnotifiers/vpn_rpc_agent_api.py @@ -0,0 +1,52 @@ +# Copyright 2020, SysEleven GbmH +# 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 neutron.api.rpc.agentnotifiers import utils as ag_utils +from neutron_lib import rpc as n_rpc +import oslo_messaging + +from neutron_vpnaas.services.vpn.common import topics + +# default messaging timeout is 60 sec, so 2 here is chosen to not block API +# call for more than 2 minutes +AGENT_NOTIFY_MAX_ATTEMPTS = 2 + + +class VPNAgentNotifyAPI(object): + """API for plugin to notify VPN agent.""" + + def __init__(self, topic=topics.IPSEC_AGENT_TOPIC): + target = oslo_messaging.Target(topic=topic, version='1.0') + self.client = n_rpc.get_client(target) + + def agent_updated(self, context, admin_state_up, host): + cctxt = self.client.prepare(server=host) + cctxt.cast(context, 'agent_updated', + payload={'admin_state_up': admin_state_up}) + + def vpnservice_removed_from_agent(self, context, router_id, host): + """Notify agent about removed VPN service(s) of a router.""" + cctxt = self.client.prepare(server=host) + cctxt.cast(context, 'vpnservice_removed_from_agent', + router_id=router_id) + + def vpnservice_added_to_agent(self, context, router_ids, host): + """Notify agent about added VPN service(s) of router(s).""" + # need to use call here as we want to be sure agent received + # notification and router will not be "lost". However using call() + # itself is not a guarantee, calling code should handle exceptions and + # retry + cctxt = self.client.prepare(server=host) + call = ag_utils.retry(cctxt.call, AGENT_NOTIFY_MAX_ATTEMPTS) + call(context, 'vpnservice_added_to_agent', router_ids=router_ids) diff --git a/neutron_vpnaas/cmd/eventlet/__init__.py b/neutron_vpnaas/cmd/eventlet/__init__.py new file mode 100644 index 000000000..590d3aa10 --- /dev/null +++ b/neutron_vpnaas/cmd/eventlet/__init__.py @@ -0,0 +1,3 @@ +from neutron.common import eventlet_utils + +eventlet_utils.monkey_patch() diff --git a/neutron_vpnaas/cmd/eventlet/ovn_agent.py b/neutron_vpnaas/cmd/eventlet/ovn_agent.py new file mode 100644 index 000000000..e8a0c3339 --- /dev/null +++ b/neutron_vpnaas/cmd/eventlet/ovn_agent.py @@ -0,0 +1,19 @@ +# Copyright 2023 SysEleven GmbH +# +# 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 neutron_vpnaas.services.vpn import ovn_agent + + +def main(): + ovn_agent.main() diff --git a/neutron_vpnaas/db/migration/alembic_migrations/versions/2023.2/expand/22e0145ac80b_add_vpn_gateway_port.py b/neutron_vpnaas/db/migration/alembic_migrations/versions/2023.2/expand/22e0145ac80b_add_vpn_gateway_port.py new file mode 100644 index 000000000..6d8172709 --- /dev/null +++ b/neutron_vpnaas/db/migration/alembic_migrations/versions/2023.2/expand/22e0145ac80b_add_vpn_gateway_port.py @@ -0,0 +1,57 @@ +# Copyright 2016 MingShuang Xian/IBM +# Copyright 2023 SysEleven GmbH +# +# 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. +# + +"""Add table for vpn gateway (gateway port and transit network) + +Revision ID: 22e0145ac80b +Revises: 3b739d6906cf +Create Date: 2016-09-18 09:01:18.660362 + +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '22e0145ac80b' +down_revision = '3b739d6906cf' + + +def upgrade(): + op.create_table( + 'vpn_ext_gws', + sa.Column('id', sa.String(length=36), nullable=False, + primary_key=True), + sa.Column('project_id', sa.String(length=255), + index=True), + sa.Column('router_id', sa.String(length=36), nullable=False, + unique=True), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('gw_port_id', sa.String(length=36)), + sa.Column('transit_port_id', sa.String(length=36)), + sa.Column('transit_network_id', sa.String(length=36)), + sa.Column('transit_subnet_id', sa.String(length=36)), + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['router_id'], ['routers.id']), + sa.ForeignKeyConstraint(['gw_port_id'], ['ports.id'], + ondelete='SET NULL'), + sa.ForeignKeyConstraint(['transit_port_id'], ['ports.id'], + ondelete='SET NULL'), + sa.ForeignKeyConstraint(['transit_network_id'], ['networks.id'], + ondelete='SET NULL'), + sa.ForeignKeyConstraint(['transit_subnet_id'], ['subnets.id'], + ondelete='SET NULL'), + ) diff --git a/neutron_vpnaas/db/migration/alembic_migrations/versions/2023.2/expand/3b739d6906cf_vpn_scheduler.py b/neutron_vpnaas/db/migration/alembic_migrations/versions/2023.2/expand/3b739d6906cf_vpn_scheduler.py new file mode 100644 index 000000000..7dc889927 --- /dev/null +++ b/neutron_vpnaas/db/migration/alembic_migrations/versions/2023.2/expand/3b739d6906cf_vpn_scheduler.py @@ -0,0 +1,41 @@ +# Copyright 2016 MingShuang Xian/IBM +# +# 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. +# + +"""vpn scheduler + +Revision ID: 3b739d6906cf +Revises: 5f884db48ba9 +Create Date: 2016-08-15 03:32:46.124718 + +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '3b739d6906cf' +down_revision = '5f884db48ba9' + + +def upgrade(): + op.create_table( + 'routervpnagentbindings', + sa.Column('router_id', sa.String(length=36), + unique=True, nullable=False), + sa.Column('vpn_agent_id', sa.String(length=36), nullable=False), + sa.ForeignKeyConstraint(['router_id'], ['routers.id'], + ondelete='CASCADE'), + sa.PrimaryKeyConstraint('router_id', 'vpn_agent_id'), + ) diff --git a/neutron_vpnaas/db/migration/alembic_migrations/versions/EXPAND_HEAD b/neutron_vpnaas/db/migration/alembic_migrations/versions/EXPAND_HEAD index d4a9fdef7..920fa6633 100644 --- a/neutron_vpnaas/db/migration/alembic_migrations/versions/EXPAND_HEAD +++ b/neutron_vpnaas/db/migration/alembic_migrations/versions/EXPAND_HEAD @@ -1 +1 @@ -5f884db48ba9 +22e0145ac80b diff --git a/neutron_vpnaas/db/models/head.py b/neutron_vpnaas/db/models/head.py index 06deb5edc..867613dd6 100644 --- a/neutron_vpnaas/db/models/head.py +++ b/neutron_vpnaas/db/models/head.py @@ -23,7 +23,9 @@ Based on this comparison database can be healed with healing migration. from neutron.db.migration.models import head +from neutron_vpnaas.db.vpn import vpn_agentschedulers_db # noqa from neutron_vpnaas.db.vpn import vpn_db # noqa +from neutron_vpnaas.db.vpn import vpn_ext_gw_db # noqa def get_metadata(): diff --git a/neutron_vpnaas/db/vpn/vpn_agentschedulers_db.py b/neutron_vpnaas/db/vpn/vpn_agentschedulers_db.py new file mode 100644 index 000000000..a0dca64cb --- /dev/null +++ b/neutron_vpnaas/db/vpn/vpn_agentschedulers_db.py @@ -0,0 +1,415 @@ +# Copyright (c) 2013 OpenStack Foundation. +# Copyright (c) 2023 SysEleven GmbH. +# 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. + +import random + +from neutron.extensions import router_availability_zone as router_az +from neutron import worker as neutron_worker +from neutron_lib import context as ncontext +from neutron_lib.db import api as db_api +from neutron_lib.db import model_base +from neutron_lib.plugins import constants as plugin_const +from neutron_lib.plugins import directory +from oslo_config import cfg +from oslo_db import exception as db_exc +from oslo_log import log as logging +import oslo_messaging +import sqlalchemy as sa +from sqlalchemy import func + +from neutron_vpnaas._i18n import _ +from neutron_vpnaas.db.vpn import vpn_models +from neutron_vpnaas.extensions import vpn_agentschedulers +from neutron_vpnaas.services.vpn.common.constants import AGENT_TYPE_VPN + + +LOG = logging.getLogger(__name__) + +VPN_AGENTS_SCHEDULER_OPTS = [ + cfg.StrOpt('vpn_scheduler_driver', + default='neutron_vpnaas.scheduler.vpn_agent_scheduler' + '.LeastRoutersScheduler', + help=_('Driver to use for scheduling ' + 'router to a VPN agent')), + cfg.BoolOpt('vpn_auto_schedule', default=True, + help=_('Allow auto scheduling of routers to VPN agent.')), + cfg.BoolOpt('allow_automatic_vpnagent_failover', default=False, + help=_('Automatically reschedule routers from offline VPN ' + 'agents to online VPN agents.')), +] + +cfg.CONF.register_opts(VPN_AGENTS_SCHEDULER_OPTS) + + +class RouterVPNAgentBinding(model_base.BASEV2): + """Represents binding between neutron routers and VPN agents.""" + + router_id = sa.Column(sa.String(36), + sa.ForeignKey("routers.id", ondelete='CASCADE'), + primary_key=True, + unique=True, + nullable=False) + vpn_agent_id = sa.Column(sa.String(36), primary_key=True, nullable=False) + + +class VPNAgentSchedulerDbMixin( + vpn_agentschedulers.VPNAgentSchedulerPluginBase): + """Mixin class to add VPN agent scheduler extension to plugins + using the VPN agent. + """ + + vpn_scheduler = None + agent_notifiers = {} + + @property + def l3_plugin(self): + return directory.get_plugin(plugin_const.L3) + + @property + def core_plugin(self): + return directory.get_plugin() + + def add_periodic_vpn_agent_status_check(self): + if not cfg.CONF.allow_automatic_vpnagent_failover: + LOG.info("Skipping periodic VPN agent status check because " + "automatic rescheduling is disabled.") + return + + interval = max(cfg.CONF.agent_down_time // 2, 1) + # add random initial delay to allow agents to check in after the + # neutron server first starts. random to offset multiple servers + initial_delay = random.randint(interval, interval * 2) + + check_worker = neutron_worker.PeriodicWorker( + self.reschedule_vpnservices_from_down_agents, + interval, initial_delay) + self.add_worker(check_worker) + + def reschedule_vpnservices_from_down_agents(self): + """Reschedule VPN services from down VPN agents. + + VPN services are scheduled per router. + """ + context = ncontext.get_admin_context() + try: + down_bindings = self.get_down_router_bindings(context) + + agents_back_online = set() + for binding in down_bindings: + if binding.vpn_agent_id in agents_back_online: + continue + agent = self.core_plugin.get_agent(context, + binding.vpn_agent_id) + if agent['alive']: + agents_back_online.add(binding.vpn_agent_id) + continue + + LOG.warning( + "Rescheduling vpn services for router %(router)s from " + "agent %(agent)s because the agent is not alive.", + {'router': binding.router_id, + 'agent': binding.vpn_agent_id}) + try: + self.reschedule_router(context, binding.router_id, agent) + except (vpn_agentschedulers.RouterReschedulingFailed, + oslo_messaging.RemoteError): + # Catch individual rescheduling errors here + # so one broken one doesn't stop the iteration. + LOG.exception("Failed to reschedule vpn services for " + "router %s", binding.router_id) + except Exception: + # we want to be thorough and catch whatever is raised + # to avoid loop abortion + LOG.exception("Exception encountered during vpn service " + "rescheduling.") + + @db_api.CONTEXT_READER + def get_down_router_bindings(self, context): + vpn_agents = self.get_vpn_agents(context, active=False) + if not vpn_agents: + return [] + vpn_agent_ids = [vpn_agent['id'] for vpn_agent in vpn_agents] + + query = context.session.query(RouterVPNAgentBinding) + query = query.filter( + RouterVPNAgentBinding.vpn_agent_id.in_(vpn_agent_ids)) + return query.all() + + def validate_agent_router_combination(self, context, agent, router): + """Validate if the router can be correctly assigned to the agent. + + :raises: InvalidVPNAgent if attempting to assign router to an + unsuitable agent (disabled, type != VPN, incompatible configuration) + """ + if agent['agent_type'] != AGENT_TYPE_VPN: + raise vpn_agentschedulers.InvalidVPNAgent(id=agent['id']) + + @db_api.CONTEXT_READER + def check_agent_router_scheduling_needed(self, context, agent, router): + """Check if the scheduling of router's VPN services is needed. + + :raises: RouterHostedByVPNAgent if router is already assigned + to a different agent. + :returns: True if scheduling is needed, otherwise False + """ + router_id = router['id'] + agent_id = agent['id'] + query = context.session.query(RouterVPNAgentBinding) + bindings = query.filter_by(router_id=router_id).all() + if not bindings: + return True + for binding in bindings: + if binding.vpn_agent_id == agent_id: + # router already bound to the agent we need + return False + # Router is already bound to some agent + raise vpn_agentschedulers.RouterHostedByVPNAgent( + router_id=router_id, + agent_id=bindings[0].vpn_agent_id) + + def create_router_to_agent_binding(self, context, router_id, agent_id): + """Create router to VPN agent binding.""" + try: + with db_api.CONTEXT_WRITER.using(context): + binding = RouterVPNAgentBinding() + binding.vpn_agent_id = agent_id + binding.router_id = router_id + context.session.add(binding) + except db_exc.DBDuplicateEntry: + LOG.debug('VPN service of router %(router_id)s has already been ' + 'scheduled to a VPN agent.', + {'router_id': router_id}) + return False + except db_exc.DBReferenceError: + LOG.debug('Router %s has already been removed ' + 'by concurrent operation', router_id) + return False + + LOG.debug('VPN service of router %(router_id)s is scheduled to ' + 'VPN agent %(agent_id)s', + {'router_id': router_id, 'agent_id': agent_id}) + return True + + def add_router_to_vpn_agent(self, context, agent_id, router_id): + """Add a VPN agent to host VPN services of a router.""" + with db_api.CONTEXT_WRITER.using(context): + router = self.l3_plugin.get_router(context, router_id) + agent = self.core_plugin.get_agent(context, agent_id) + self.validate_agent_router_combination(context, agent, router) + if not self.check_agent_router_scheduling_needed( + context, agent, router): + return + try: + success = self.create_router_to_agent_binding( + context, router['id'], agent['id']) + except db_exc.DBError: + success = False + + if not success: + raise vpn_agentschedulers.RouterSchedulingFailed( + router_id=router_id, agent_id=agent_id) + + # notify agent + vpn_notifier = self.agent_notifiers.get(AGENT_TYPE_VPN) + if vpn_notifier: + vpn_notifier.vpnservice_added_to_agent( + context, [router_id], agent['host']) + + # update port binding + self.vpn_router_agent_binding_changed( + context, router_id, agent['host']) + + def remove_router_from_vpn_agent(self, context, agent_id, router_id): + """Remove the router from VPN agent. + + After removal, the VPN service(s) of the router will be non-hosted + until there is an update which leads to re-schedule or the router is + added to another agent manually. + """ + agent = self.core_plugin.get_agent(context, agent_id) + + self._unbind_router(context, router_id, agent_id) + + vpn_notifier = self.agent_notifiers.get(AGENT_TYPE_VPN) + if vpn_notifier: + vpn_notifier.vpnservice_removed_from_agent( + context, router_id, agent['host']) + + def _unbind_router(self, context, router_id, agent_id): + with db_api.CONTEXT_WRITER.using(context): + query = context.session.query(RouterVPNAgentBinding) + query = query.filter( + RouterVPNAgentBinding.router_id == router_id, + RouterVPNAgentBinding.vpn_agent_id == agent_id) + return query.delete() + + def reschedule_router(self, context, router_id, cur_agent): + """Reschedule router to a new VPN agent + + Remove the router from the agent currently hosting it and + schedule it again + """ + with db_api.CONTEXT_WRITER.using(context): + deleted = self._unbind_router(context, router_id, cur_agent['id']) + if not deleted: + # If nothing was deleted, the binding didn't exist anymore + # because some other server deleted the binding concurrently. + # Stop here. + return + + new_agent = self.schedule_router(context, router_id) + if not new_agent: + # No new_agent means that another server scheduled the + # router concurrently. Don't raise RouterReschedulingFailed. + return + + self._notify_agents_router_rescheduled(context, router_id, + cur_agent, new_agent) + # update port binding + self.vpn_router_agent_binding_changed( + context, router_id, new_agent['host']) + + def _notify_agents_router_rescheduled(self, context, router_id, + old_agent, new_agent): + vpn_notifier = self.agent_notifiers.get(AGENT_TYPE_VPN) + if not vpn_notifier: + return + + old_host = old_agent['host'] + new_host = new_agent['host'] + if old_host != new_host: + vpn_notifier.vpnservice_removed_from_agent( + context, router_id, old_host) + + try: + vpn_notifier.vpnservice_added_to_agent( + context, [router_id], new_host) + except oslo_messaging.MessagingException: + self._unbind_router(context, router_id, new_agent['id']) + raise vpn_agentschedulers.RouterReschedulingFailed( + router_id=router_id) + + @db_api.CONTEXT_READER + def list_routers_on_vpn_agent(self, context, agent_id): + query = context.session.query(RouterVPNAgentBinding.router_id) + query = query.filter(RouterVPNAgentBinding.vpn_agent_id == agent_id) + + router_ids = [item[0] for item in query] + if router_ids: + return {'routers': + self.l3_plugin.get_routers(context, + filters={'id': router_ids})} + else: + # Exception will be thrown if the requested agent does not exist. + self.core_plugin.get_agent(context, agent_id) + return {'routers': []} + + @db_api.CONTEXT_READER + def get_vpn_agents_hosting_routers(self, context, router_ids, active=None): + if not router_ids: + return [] + query = context.session.query(RouterVPNAgentBinding) + query = query.filter(RouterVPNAgentBinding.router_id.in_(router_ids)) + + filters = {'id': [binding.vpn_agent_id for binding in query]} + vpn_agents = self.core_plugin.get_agents(context, filters=filters) + if active is not None: + vpn_agents = [agent + for agent in vpn_agents + if agent['alive'] == active] + return vpn_agents + + def list_vpn_agents_hosting_router(self, context, router_id): + vpn_agents = self.get_vpn_agents_hosting_routers(context, [router_id]) + return {'agents': vpn_agents} + + def get_vpn_agents(self, context, active=None, host=None): + filters = {'agent_type': [AGENT_TYPE_VPN]} + if host is not None: + filters['host'] = [host] + vpn_agents = self.core_plugin.get_agents(context, filters=filters) + if active is None: + return vpn_agents + else: + return [vpn_agent + for vpn_agent in vpn_agents + if vpn_agent['alive'] == active] + + def get_vpn_agent_on_host(self, context, host, active=None): + agents = self.get_vpn_agents(context, active=active, host=host) + if agents: + return agents[0] + + @db_api.CONTEXT_READER + def get_unscheduled_vpn_routers(self, context, router_ids=None): + """Get IDs of routers which have unscheduled VPN services.""" + query = context.session.query(vpn_models.VPNService.router_id) + query = query.outerjoin( + RouterVPNAgentBinding, + vpn_models.VPNService.router_id == RouterVPNAgentBinding.router_id) + query = query.filter(RouterVPNAgentBinding.vpn_agent_id.is_(None)) + if router_ids: + query = query.filter( + vpn_models.VPNService.router_id.in_(router_ids)) + return [router_id for router_id, in query.all()] + + def auto_schedule_routers(self, context, vpn_agent): + if self.vpn_scheduler: + return self.vpn_scheduler.auto_schedule_routers( + self, context, vpn_agent) + + def schedule_router(self, context, router, candidates=None): + """Schedule VPN services of a router to a VPN agent. + + Returns the chosen agent; None if another server scheduled the + router concurrently. + Raises RouterReschedulingFailed if no suitable agent is found. + """ + if self.vpn_scheduler: + return self.vpn_scheduler.schedule( + self, context, router, candidates=candidates) + + @db_api.CONTEXT_READER + def get_vpn_agent_with_min_routers(self, context, agent_ids): + """Return VPN agent with the least number of routers.""" + if not agent_ids: + return None + query = context.session.query( + RouterVPNAgentBinding.vpn_agent_id, + func.count(RouterVPNAgentBinding.router_id).label('count')) + query = query.group_by(RouterVPNAgentBinding.vpn_agent_id) + query = query.order_by('count') + query = query.filter(RouterVPNAgentBinding.vpn_agent_id.in_(agent_ids)) + used_agent_ids = [agent_id for agent_id, _ in query.all()] + unused_agent_ids = set(agent_ids) - set(used_agent_ids) + if unused_agent_ids: + return unused_agent_ids.pop() + else: + return used_agent_ids[0] + + def get_hosts_to_notify(self, context, router_id): + """Returns all hosts to send notification about router update""" + agents = self.get_vpn_agents_hosting_routers(context, [router_id], + active=True) + return [a['host'] for a in agents] + + +class AZVPNAgentSchedulerDbMixin(VPNAgentSchedulerDbMixin, + router_az.RouterAvailabilityZonePluginBase): + """Mixin class to add availability_zone supported VPN agent scheduler.""" + + def get_router_availability_zones(self, router): + return list({agent.availability_zone for agent in router.vpn_agents}) diff --git a/neutron_vpnaas/db/vpn/vpn_db.py b/neutron_vpnaas/db/vpn/vpn_db.py index 1834e8202..f7f3257a5 100644 --- a/neutron_vpnaas/db/vpn/vpn_db.py +++ b/neutron_vpnaas/db/vpn/vpn_db.py @@ -509,6 +509,19 @@ class VPNPluginDb(vpnaas.VPNPluginBase, vpns_db.update(vpns) return self._make_vpnservice_dict(vpns_db) + def set_vpnservice_status(self, context, vpnservice_id, status, + updated_pending_status=False): + vpns = {'status': status} + with db_api.CONTEXT_WRITER.using(context): + vpns_db = self._get_resource(context, vpn_models.VPNService, + vpnservice_id) + if (utils.in_pending_status(vpns_db.status) and + not updated_pending_status): + raise vpnaas.VPNStateInvalidToUpdate( + id=vpnservice_id, state=vpns_db.status) + vpns_db.update(vpns) + return self._make_vpnservice_dict(vpns_db) + def update_vpnservice(self, context, vpnservice_id, vpnservice): vpns = vpnservice['vpnservice'] with db_api.CONTEXT_WRITER.using(context): @@ -682,6 +695,22 @@ class VPNPluginDb(vpnaas.VPNPluginBase, vpnservice = self._get_vpnservice(context, vpnservice_id) return vpnservice['router_id'] + @db_api.CONTEXT_READER + def get_peer_cidrs_for_router(self, context, router_id): + filters = {'router_id': [router_id]} + vpnservices = model_query.get_collection_query( + context, vpn_models.VPNService, filters=filters).all() + cidrs = [] + for vpnservice in vpnservices: + for ipsec_site_connection in vpnservice.ipsec_site_connections: + if ipsec_site_connection.peer_cidrs: + for peer_cidr in ipsec_site_connection.peer_cidrs: + cidrs.append(peer_cidr.cidr) + if ipsec_site_connection.peer_ep_group is not None: + for ep in ipsec_site_connection.peer_ep_group.endpoints: + cidrs.append(ep.endpoint) + return cidrs + class VPNPluginRpcDbMixin(object): def _build_local_subnet_cidr_map(self, context): diff --git a/neutron_vpnaas/db/vpn/vpn_ext_gw_db.py b/neutron_vpnaas/db/vpn/vpn_ext_gw_db.py new file mode 100644 index 000000000..3b3e26342 --- /dev/null +++ b/neutron_vpnaas/db/vpn/vpn_ext_gw_db.py @@ -0,0 +1,236 @@ +# (c) Copyright 2016 IBM Corporation, All Rights Reserved. +# (c) Copyright 2023 SysEleven GmbH +# 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 neutron.db.models import l3 as l3_models +from neutron.db import models_v2 +from neutron_lib.callbacks import events +from neutron_lib.callbacks import registry +from neutron_lib.callbacks import resources +from neutron_lib import constants as lib_constants +from neutron_lib.db import api as db_api +from neutron_lib.db import model_base +from neutron_lib.db import model_query +from neutron_lib import exceptions as n_exc +from neutron_lib.plugins import constants as plugin_const +from neutron_lib.plugins import directory +from oslo_log import log as logging +from oslo_utils import uuidutils +import sqlalchemy as sa +from sqlalchemy import orm +from sqlalchemy.orm import exc + +from neutron_vpnaas._i18n import _ +from neutron_vpnaas.services.vpn.common import constants as v_constants + + +LOG = logging.getLogger(__name__) + + +class RouterIsNotVPNExternal(n_exc.BadRequest): + message = _("Router %(router_id)s has no VPN external network gateway set") + + +class RouterHasVPNExternal(n_exc.BadRequest): + message = _( + "Router %(router_id)s already has VPN external network gateway") + + +class VPNNetworkInUse(n_exc.NetworkInUse): + message = _("Network %(network_id)s is used by VPN service") + + +class VPNExtGW(model_base.BASEV2, model_base.HasId, model_base.HasProject): + __tablename__ = 'vpn_ext_gws' + router_id = sa.Column(sa.String(36), sa.ForeignKey('routers.id'), + nullable=False, unique=True) + status = sa.Column(sa.String(16), nullable=False) + gw_port_id = sa.Column( + sa.String(36), + sa.ForeignKey('ports.id', ondelete='SET NULL')) + transit_port_id = sa.Column( + sa.String(36), + sa.ForeignKey('ports.id', ondelete='SET NULL')) + transit_network_id = sa.Column( + sa.String(36), + sa.ForeignKey('networks.id', ondelete='SET NULL')) + transit_subnet_id = sa.Column( + sa.String(36), + sa.ForeignKey('subnets.id', ondelete='SET NULL')) + + gw_port = orm.relationship(models_v2.Port, lazy='joined', + foreign_keys=[gw_port_id]) + transit_port = orm.relationship(models_v2.Port, lazy='joined', + foreign_keys=[transit_port_id]) + transit_network = orm.relationship(models_v2.Network) + transit_subnet = orm.relationship(models_v2.Subnet) + router = orm.relationship(l3_models.Router) + + +@registry.has_registry_receivers +class VPNExtGWPlugin_db(object): + """DB class to support vpn external ports configuration.""" + + @property + def _core_plugin(self): + return directory.get_plugin() + + @property + def _vpn_plugin(self): + return directory.get_plugin(plugin_const.VPN) + + @staticmethod + @registry.receives(resources.PORT, [events.BEFORE_DELETE]) + def _prevent_vpn_port_delete_callback(resource, event, + trigger, payload=None): + vpn_plugin = directory.get_plugin(plugin_const.VPN) + if vpn_plugin: + vpn_plugin.prevent_vpn_port_deletion(payload.context, + payload.resource_id) + + @db_api.CONTEXT_READER + def _id_used(self, context, id_column, resource_id): + return context.session.query(VPNExtGW).filter( + sa.and_( + id_column == resource_id, + VPNExtGW.status != lib_constants.PENDING_DELETE + ) + ).count() > 0 + + def prevent_vpn_port_deletion(self, context, port_id): + """Checks to make sure a port is allowed to be deleted. + + Raises an exception if this is not the case. This should be called by + any plugin when the API requests the deletion of a port, since some + ports for L3 are not intended to be deleted directly via a DELETE + to /ports, but rather via other API calls that perform the proper + deletion checks. + """ + try: + port = self._core_plugin.get_port(context, port_id) + except n_exc.PortNotFound: + # non-existent ports don't need to be protected from deletion + return + + port_id_column = { + v_constants.DEVICE_OWNER_VPN_ROUTER_GW: VPNExtGW.gw_port_id, + v_constants.DEVICE_OWNER_TRANSIT_NETWORK: + VPNExtGW.transit_port_id, + }.get(port['device_owner']) + + if not port_id_column: + # This is not a VPN port + return + + if self._id_used(context, port_id_column, port_id): + reason = _('has device owner %s') % port['device_owner'] + raise n_exc.ServicePortInUse(port_id=port['id'], reason=reason) + + @staticmethod + @registry.receives(resources.SUBNET, [events.BEFORE_DELETE]) + def _prevent_vpn_subnet_delete_callback(resource, event, + trigger, payload=None): + vpn_plugin = directory.get_plugin(plugin_const.VPN) + if vpn_plugin: + vpn_plugin.prevent_vpn_subnet_deletion(payload.context, + payload.resource_id) + + def prevent_vpn_subnet_deletion(self, context, subnet_id): + if self._id_used(context, VPNExtGW.transit_subnet_id, subnet_id): + reason = _('Subnet is used by VPN service') + raise n_exc.SubnetInUse(subnet_id=subnet_id, reason=reason) + + @staticmethod + @registry.receives(resources.NETWORK, [events.BEFORE_DELETE]) + def _prevent_vpn_network_delete_callback(resource, event, + trigger, payload=None): + vpn_plugin = directory.get_plugin(plugin_const.VPN) + if vpn_plugin: + vpn_plugin.prevent_vpn_network_deletion(payload.context, + payload.resource_id) + + def prevent_vpn_network_deletion(self, context, network_id): + if self._id_used(context, VPNExtGW.transit_network_id, network_id): + raise VPNNetworkInUse(network_id=network_id) + + def _make_vpn_ext_gw_dict(self, gateway_db): + if not gateway_db: + return None + gateway = { + 'id': gateway_db['id'], + 'tenant_id': gateway_db['tenant_id'], + 'router_id': gateway_db['router_id'], + 'status': gateway_db['status'], + } + if gateway_db.gw_port: + gateway['network_id'] = gateway_db.gw_port['network_id'] + gateway['external_fixed_ips'] = [ + {'subnet_id': ip["subnet_id"], 'ip_address': ip["ip_address"]} + for ip in gateway_db.gw_port['fixed_ips'] + ] + for key in ('gw_port_id', 'transit_port_id', 'transit_network_id', + 'transit_subnet_id'): + value = gateway_db.get(key) + if value: + gateway[key] = value + return gateway + + def _get_vpn_gw_by_router_id(self, context, router_id): + try: + gateway_db = context.session.query(VPNExtGW).filter( + VPNExtGW.router_id == router_id).one() + except exc.NoResultFound: + return None + return gateway_db + + @db_api.CONTEXT_READER + def get_vpn_gw_by_router_id(self, context, router_id): + return self._get_vpn_gw_by_router_id(context, router_id) + + @db_api.CONTEXT_READER + def get_vpn_gw_dict_by_router_id(self, context, router_id, refresh=False): + gateway_db = self._get_vpn_gw_by_router_id(context, router_id) + if gateway_db and refresh: + context.session.refresh(gateway_db) + return self._make_vpn_ext_gw_dict(gateway_db) + + def create_gateway(self, context, gateway): + info = gateway['gateway'] + + with db_api.CONTEXT_WRITER.using(context): + gateway_db = VPNExtGW( + id=uuidutils.generate_uuid(), + tenant_id=info['tenant_id'], + router_id=info['router_id'], + status=lib_constants.PENDING_CREATE, + gw_port_id=info.get('gw_port_id'), + transit_port_id=info.get('transit_port_id'), + transit_network_id=info.get('transit_network_id'), + transit_subnet_id=info.get('transit_subnet_id')) + context.session.add(gateway_db) + + return self._make_vpn_ext_gw_dict(gateway_db) + + def update_gateway(self, context, gateway_id, gateway): + info = gateway['gateway'] + with db_api.CONTEXT_WRITER.using(context): + gateway_db = model_query.get_by_id(context, VPNExtGW, gateway_id) + gateway_db.update(info) + return self._make_vpn_ext_gw_dict(gateway_db) + + def delete_gateway(self, context, gateway_id): + with db_api.CONTEXT_WRITER.using(context): + query = context.session.query(VPNExtGW) + return query.filter(VPNExtGW.id == gateway_id).delete() diff --git a/neutron_vpnaas/extensions/vpn_agentschedulers.py b/neutron_vpnaas/extensions/vpn_agentschedulers.py new file mode 100644 index 000000000..0a09bb30a --- /dev/null +++ b/neutron_vpnaas/extensions/vpn_agentschedulers.py @@ -0,0 +1,190 @@ +# (c) Copyright 2016 IBM Corporation, 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. + +import abc + +from neutron.api import extensions +from neutron.api.v2 import resource +from neutron import policy +from neutron import wsgi +from neutron_lib.api import extensions as lib_extensions +from neutron_lib.api import faults as base +from neutron_lib import exceptions +from neutron_lib.plugins import constants as plugin_const +from neutron_lib.plugins import directory +from neutron_lib import rpc as n_rpc +from oslo_log import log as logging +import webob.exc + + +LOG = logging.getLogger(__name__) + + +VPN_ROUTER = 'vpn-router' +VPN_ROUTERS = VPN_ROUTER + 's' +VPN_AGENT = 'vpn-agent' +VPN_AGENTS = VPN_AGENT + 's' + + +class VPNRouterSchedulerController(wsgi.Controller): + def get_plugin(self): + plugin = directory.get_plugin(plugin_const.VPN) + if not plugin: + LOG.error('No plugin for VPN registered to handle VPN ' + 'router scheduling') + msg = 'The resource could not be found.' + raise webob.exc.HTTPNotFound(msg) + return plugin + + def index(self, request, **kwargs): + plugin = self.get_plugin() + policy.enforce(request.context, + "get_%s" % VPN_ROUTERS, + {}) + return plugin.list_routers_on_vpn_agent( + request.context, kwargs['agent_id']) + + def create(self, request, body, **kwargs): + plugin = self.get_plugin() + policy.enforce(request.context, + "create_%s" % VPN_ROUTER, + {}) + agent_id = kwargs['agent_id'] + router_id = body['router_id'] + result = plugin.add_router_to_vpn_agent(request.context, agent_id, + router_id) + notify(request.context, 'vpn_agent.router.add', router_id, agent_id) + return result + + def delete(self, request, id, **kwargs): + plugin = self.get_plugin() + policy.enforce(request.context, + "delete_%s" % VPN_ROUTER, + {}) + agent_id = kwargs['agent_id'] + result = plugin.remove_router_from_vpn_agent(request.context, agent_id, + id) + notify(request.context, 'vpn_agent.router.remove', id, agent_id) + return result + + +class VPNAgentsHostingRouterController(wsgi.Controller): + def get_plugin(self): + plugin = directory.get_plugin(plugin_const.VPN) + if not plugin: + LOG.error('VPN plugin not registered to handle agent scheduling') + msg = 'The resource could not be found.' + raise webob.exc.HTTPNotFound(msg) + return plugin + + def index(self, request, **kwargs): + plugin = self.get_plugin() + policy.enforce(request.context, + "get_%s" % VPN_AGENTS, + {}) + return plugin.list_vpn_agents_hosting_router( + request.context, kwargs['router_id']) + + +class Vpn_agentschedulers(lib_extensions.ExtensionDescriptor): + """Extension class supporting VPN agent scheduler. + """ + + @classmethod + def get_name(cls): + return "VPN Agent Scheduler" + + @classmethod + def get_alias(cls): + return "vpn-agent-scheduler" + + @classmethod + def get_description(cls): + return "Schedule VPN services of routers among VPN agents" + + @classmethod + def get_updated(cls): + return "2016-08-15T10:00:00-00:00" + + @classmethod + def get_resources(cls): + """Returns Ext Resources.""" + exts = [] + parent = dict(member_name="agent", + collection_name="agents") + + controller = resource.Resource(VPNRouterSchedulerController(), + base.FAULT_MAP) + exts.append(extensions.ResourceExtension( + VPN_ROUTERS, controller, parent)) + + parent = dict(member_name="router", + collection_name="routers") + + controller = resource.Resource(VPNAgentsHostingRouterController(), + base.FAULT_MAP) + exts.append(extensions.ResourceExtension( + VPN_AGENTS, controller, parent)) + return exts + + def get_extended_resources(self, version): + return {} + + +class InvalidVPNAgent(exceptions.agent.AgentNotFound): + message = "Agent %(id)s is not a VPN Agent or has been disabled" + + +class RouterHostedByVPNAgent(exceptions.Conflict): + message = ("The VPN service of router %(router_id)s has been already " + "hosted by the VPN Agent %(agent_id)s.") + + +class RouterSchedulingFailed(exceptions.Conflict): + message = ("Failed scheduling router %(router_id)s to the VPN Agent " + "%(agent_id)s.") + + +class RouterReschedulingFailed(exceptions.Conflict): + message = ("Failed rescheduling router %(router_id)s: " + "No eligible VPN agent found.") + + +class VPNAgentSchedulerPluginBase(object, metaclass=abc.ABCMeta): + """REST API to operate the VPN agent scheduler. + + All methods must be in an admin context. + """ + + @abc.abstractmethod + def add_router_to_vpn_agent(self, context, id, router_id): + pass + + @abc.abstractmethod + def remove_router_from_vpn_agent(self, context, id, router_id): + pass + + @abc.abstractmethod + def list_routers_on_vpn_agent(self, context, id): + pass + + @abc.abstractmethod + def list_vpn_agents_hosting_router(self, context, router_id): + pass + + +def notify(context, action, router_id, agent_id): + info = {'id': agent_id, 'router_id': router_id} + notifier = n_rpc.get_notifier('router') + notifier.info(context, action, {'agent': info}) diff --git a/neutron_vpnaas/extensions/vpnaas.py b/neutron_vpnaas/extensions/vpnaas.py index e3b9d6883..c2ffc6c84 100644 --- a/neutron_vpnaas/extensions/vpnaas.py +++ b/neutron_vpnaas/extensions/vpnaas.py @@ -17,11 +17,35 @@ import abc from neutron_lib.api.definitions import vpn from neutron_lib.api import extensions +from neutron_lib import exceptions as nexception from neutron_lib.plugins import constants as nconstants from neutron_lib.services import base as service_base from neutron.api.v2 import resource_helper +from neutron_vpnaas._i18n import _ + + +class RouteInUseByVPN(nexception.InUse): + """Operational error indicating a route is used for VPN. + + :param destinations: Destination CIDRs that are peers for VPN + """ + message = _("Route(s) to %(destinations)s are used for VPN") + + +class VPNGatewayNotReady(nexception.BadRequest): + message = _("VPN gateway not ready") + + +class VPNGatewayInError(nexception.Conflict): + message = _("VPN gateway is in ERROR state. " + "Please remove all errored VPN services and try again.") + + +class NoVPNAgentAvailable(nexception.ServiceUnavailable): + message = _("No VPN agent available") + class Vpnaas(extensions.APIExtensionDescriptor): api_definition = vpn diff --git a/neutron_vpnaas/opts.py b/neutron_vpnaas/opts.py index 568eef271..ef7d3bbd7 100644 --- a/neutron_vpnaas/opts.py +++ b/neutron_vpnaas/opts.py @@ -10,11 +10,13 @@ # License for the specific language governing permissions and limitations # under the License. +import neutron.conf.plugins.ml2.drivers.ovn.ovn_conf import neutron.services.provider_configuration import neutron_vpnaas.services.vpn.agent import neutron_vpnaas.services.vpn.device_drivers.ipsec import neutron_vpnaas.services.vpn.device_drivers.strongswan_ipsec +import neutron_vpnaas.services.vpn.ovn_agent def list_agent_opts(): @@ -31,6 +33,24 @@ def list_agent_opts(): ] +def list_ovn_agent_opts(): + return [ + ('vpnagent', + neutron_vpnaas.services.vpn.ovn_agent.VPN_AGENT_OPTS), + ('ovs', + neutron_vpnaas.services.vpn.ovn_agent.OVS_OPTS), + ('ovn', + neutron.conf.plugins.ml2.drivers.ovn.ovn_conf.ovn_opts), + ('ipsec', + neutron_vpnaas.services.vpn.device_drivers.ipsec.ipsec_opts), + ('strongswan', + neutron_vpnaas.services.vpn.device_drivers.strongswan_ipsec. + strongswan_opts), + ('pluto', + neutron_vpnaas.services.vpn.device_drivers.ipsec.pluto_opts) + ] + + def list_opts(): return [ ('service_providers', diff --git a/neutron_vpnaas/scheduler/vpn_agent_scheduler.py b/neutron_vpnaas/scheduler/vpn_agent_scheduler.py new file mode 100644 index 000000000..bea9bfe84 --- /dev/null +++ b/neutron_vpnaas/scheduler/vpn_agent_scheduler.py @@ -0,0 +1,185 @@ +# (c) Copyright 2016 IBM Corporation, 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. + +import abc +import random + +from neutron.extensions import availability_zone as az_ext +from neutron_lib.plugins import constants as plugin_constants +from neutron_lib.plugins import directory +from oslo_config import cfg +from oslo_log import log as logging + +from neutron_vpnaas.extensions import vpn_agentschedulers + +LOG = logging.getLogger(__name__) + + +class VPNScheduler(object, metaclass=abc.ABCMeta): + @property + def l3_plugin(self): + return directory.get_plugin(plugin_constants.L3) + + @abc.abstractmethod + def schedule(self, plugin, context, router_id, + candidates=None, hints=None): + """Schedule the router to an active VPN agent. + + Schedule the router only if it is not already scheduled. + """ + pass + + def _get_unscheduled_routers(self, context, plugin, router_ids=None): + """Get the list of routers with VPN services to be scheduled. + + If router IDs are omitted, look for all unscheduled routers. + + :param context: the context + :param plugin: the core plugin + :param router_ids: the list of routers to be checked for scheduling + :returns: the list of routers to be scheduled + """ + unscheduled_router_ids = plugin.get_unscheduled_vpn_routers( + context, router_ids=router_ids) + if unscheduled_router_ids: + return self.l3_plugin.get_routers( + context, filters={'id': unscheduled_router_ids}) + return [] + + def _get_routers_can_schedule(self, context, plugin, routers, vpn_agent): + """Get the subset of routers whose VPN services can be scheduled on + the VPN agent. + """ + # Assuming that only an active, enabled VPN agent is passed in, + # all routers can be scheduled to it + return routers + + def auto_schedule_routers(self, plugin, context, vpn_agent): + """Schedule non-hosted routers to a VPN agent. + + :returns: True if routers have been successfully assigned to the agent + """ + unscheduled_routers = self._get_unscheduled_routers(context, plugin) + + target_routers = self._get_routers_can_schedule( + context, plugin, unscheduled_routers, vpn_agent) + if not target_routers: + if unscheduled_routers: + LOG.warning('No unscheduled routers compatible with VPN agent ' + 'configuration on host %s', vpn_agent['host']) + return [] + + self._bind_routers(context, plugin, target_routers, vpn_agent) + return [router['id'] for router in target_routers] + + def _get_candidates(self, plugin, context, sync_router): + """Return VPN agents where a router could be scheduled.""" + active_vpn_agents = plugin.get_vpn_agents(context, active=True) + if not active_vpn_agents: + LOG.warning('No active VPN agents') + return active_vpn_agents + + def _bind_routers(self, context, plugin, routers, vpn_agent): + for router in routers: + plugin.create_router_to_agent_binding( + context, router['id'], vpn_agent['id']) + + def _schedule_router(self, plugin, context, router_id, + candidates=None): + current_vpn_agents = plugin.get_vpn_agents_hosting_routers( + context, [router_id]) + if current_vpn_agents: + chosen_agent = current_vpn_agents[0] + LOG.debug('VPN service of router %(router_id)s has already ' + 'been hosted by VPN agent %(agent_id)s', + {'router_id': router_id, + 'agent_id': chosen_agent}) + return chosen_agent + + sync_router = self.l3_plugin.get_router(context, router_id) + candidates = candidates or self._get_candidates( + plugin, context, sync_router) + if not candidates: + raise vpn_agentschedulers.RouterReschedulingFailed( + router_id=router_id) + + chosen_agent = self._choose_vpn_agent(plugin, context, candidates) + if plugin.create_router_to_agent_binding(context, router_id, + chosen_agent['id']): + return chosen_agent + + @abc.abstractmethod + def _choose_vpn_agent(self, plugin, context, candidates): + """Choose an agent from candidates based on a specific policy.""" + pass + + +class ChanceScheduler(VPNScheduler): + """Randomly allocate an VPN agent for a router.""" + + def schedule(self, plugin, context, router_id, + candidates=None): + return self._schedule_router( + plugin, context, router_id, candidates=candidates) + + def _choose_vpn_agent(self, plugin, context, candidates): + return random.choice(candidates) + + +class LeastRoutersScheduler(VPNScheduler): + """Allocate to an VPN agent with the least number of routers bound.""" + + def schedule(self, plugin, context, router_id, + candidates=None): + return self._schedule_router( + plugin, context, router_id, candidates=candidates) + + def _choose_vpn_agent(self, plugin, context, candidates): + candidates_dict = {c['id']: c for c in candidates} + chosen_agent_id = plugin.get_vpn_agent_with_min_routers( + context, candidates_dict.keys()) + return candidates_dict[chosen_agent_id] + + +class AZLeastRoutersScheduler(LeastRoutersScheduler): + """Availability zone aware scheduler.""" + def _get_az_hints(self, router): + return (router.get(az_ext.AZ_HINTS) or + cfg.CONF.default_availability_zones) + + def _get_routers_can_schedule(self, context, plugin, routers, vpn_agent): + """Overwrite VPNScheduler's method to filter by availability zone.""" + target_routers = [] + for r in routers: + az_hints = self._get_az_hints(r) + if not az_hints or vpn_agent['availability_zone'] in az_hints: + target_routers.append(r) + + if not target_routers: + return + + return super()._get_routers_can_schedule( + context, plugin, target_routers, vpn_agent) + + def _get_candidates(self, plugin, context, sync_router): + """Overwrite VPNScheduler's method to filter by availability zone.""" + all_candidates = super()._get_candidates(plugin, context, sync_router) + + candidates = [] + az_hints = self._get_az_hints(sync_router) + for agent in all_candidates: + if not az_hints or agent['availability_zone'] in az_hints: + candidates.append(agent) + + return candidates diff --git a/neutron_vpnaas/services/vpn/common/constants.py b/neutron_vpnaas/services/vpn/common/constants.py index b76498fa7..1719e9995 100644 --- a/neutron_vpnaas/services/vpn/common/constants.py +++ b/neutron_vpnaas/services/vpn/common/constants.py @@ -13,6 +13,8 @@ # License for the specific language governing permissions and limitations # under the License. +from neutron_lib import constants + # Endpoint group types SUBNET_ENDPOINT = 'subnet' CIDR_ENDPOINT = 'cidr' @@ -30,3 +32,15 @@ VPN_SUPPORTED_ENDPOINT_TYPES = [ SUBNET_ENDPOINT, CIDR_ENDPOINT, VLAN_ENDPOINT, NETWORK_ENDPOINT, ROUTER_ENDPOINT, ] + +AGENT_TYPE_VPN = "VPN Agent" + +DEVICE_OWNER_VPN_ROUTER_GW = constants.DEVICE_OWNER_NETWORK_PREFIX + \ + "vpn_router_gateway" + +DEVICE_OWNER_TRANSIT_NETWORK = constants.DEVICE_OWNER_NETWORK_PREFIX + \ + "vpn_namespace" + +OVN_AGENT_VPN_SB_CFG_KEY = 'neutron:ovn-vpnagent-sb-cfg' +OVN_AGENT_VPN_DESC_KEY = 'neutron:description-vpnagent' +OVN_AGENT_VPN_ID_KEY = 'neutron:ovn-vpnagent-id' diff --git a/neutron_vpnaas/services/vpn/device_drivers/ovn_ipsec.py b/neutron_vpnaas/services/vpn/device_drivers/ovn_ipsec.py new file mode 100644 index 000000000..aaaaac0ec --- /dev/null +++ b/neutron_vpnaas/services/vpn/device_drivers/ovn_ipsec.py @@ -0,0 +1,376 @@ +# Copyright (c) 2016 Yi Jing Zhu, IBM. +# Copyright (c) 2023 SysEleven GmbH +# 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. + +import netaddr + +from neutron.agent.common import utils as agent_common_utils +from neutron.agent.linux import ip_lib +from neutron_lib import constants as lib_constants +from neutron_lib import context as nctx +from oslo_concurrency import lockutils +from oslo_log import log as logging + +from neutron_vpnaas.services.vpn.common import topics +from neutron_vpnaas.services.vpn.device_drivers import ipsec +from neutron_vpnaas.services.vpn.device_drivers import libreswan_ipsec +from neutron_vpnaas.services.vpn.device_drivers import strongswan_ipsec + +PORT_PREFIX_INTERNAL = 'vr' +PORT_PREFIX_EXTERNAL = 'vg' +PORT_PREFIXES = { + 'internal': PORT_PREFIX_INTERNAL, + 'external': PORT_PREFIX_EXTERNAL, +} + +LOG = logging.getLogger(__name__) + + +class DeviceManager(object): + """Device Manager for ports in qvpn-xx namespace. + It is a veth pair, one side in qvpn and the other + side is attached to ovs. + """ + + OVN_NS_PREFIX = "qvpn-" + + def __init__(self, conf, host, plugin, context): + self.conf = conf + self.host = host + self.plugin = plugin + self.context = context + self.driver = agent_common_utils.load_interface_driver(conf) + + def get_interface_name(self, port, ptype): + suffix = port['id'] + return (PORT_PREFIXES[ptype] + suffix)[:self.driver.DEV_NAME_LEN] + + def get_namespace_name(self, process_id): + return self.OVN_NS_PREFIX + process_id + + def get_existing_process_ids(self): + """Return the process IDs derived from the existing VPN namespaces.""" + return [ns[len(self.OVN_NS_PREFIX):] + for ns in ip_lib.list_network_namespaces() + if ns.startswith(self.OVN_NS_PREFIX)] + + def set_default_route(self, namespace, subnet, device_name): + device = ip_lib.IPDevice(device_name, namespace=namespace) + gateway = device.route.get_gateway(ip_version=subnet['ip_version']) + if gateway: + gateway = gateway.get('gateway') + new_gateway = subnet['gateway_ip'] + if gateway == new_gateway: + return + device.route.add_gateway(subnet['gateway_ip']) + + def add_routes(self, namespace, cidrs, via): + device = ip_lib.IPDevice(None, namespace=namespace) + for cidr in cidrs: + device.route.add_route(cidr, via=via, metric=100, proto='static') + + def delete_routes(self, namespace, cidrs, via): + device = ip_lib.IPDevice(None, namespace=namespace) + for cidr in cidrs: + device.route.delete_route(cidr, via=via, metric=100, + proto='static') + + def list_routes(self, namespace, via=None): + device = ip_lib.IPDevice(None, namespace=namespace) + return device.route.list_routes( + lib_constants.IP_VERSION_4, proto='static', via=via) + + def del_static_routes(self, namespace): + device = ip_lib.IPDevice(None, namespace=namespace) + routes = device.route.list_routes( + lib_constants.IP_VERSION_4, proto='static') + + for r in routes: + device.route.delete_route(r['cidr'], via=r['via']) + + def _del_port(self, process_id, ptype): + namespace = self.get_namespace_name(process_id) + prefix = PORT_PREFIXES[ptype] + device = ip_lib.IPDevice(None, namespace=namespace) + ports = device.addr.list() + for p in ports: + if not p['name'].startswith(prefix): + continue + interface_name = p['name'] + self.driver.unplug(interface_name, namespace=namespace) + + def del_internal_port(self, process_id): + self._del_port(process_id, 'internal') + + def del_external_port(self, process_id): + self._del_port(process_id, 'external') + + def setup_external(self, process_id, network_details): + network = network_details["external_network"] + vpn_port = network_details['gw_port'] + ns_name = self.get_namespace_name(process_id) + interface_name = self.get_interface_name(vpn_port, 'external') + + if not ip_lib.ensure_device_is_ready(interface_name, + namespace=ns_name): + try: + self.driver.plug(network['id'], + vpn_port['id'], + interface_name, + vpn_port['mac_address'], + namespace=ns_name, + mtu=network.get('mtu'), + prefix=PORT_PREFIX_EXTERNAL) + except Exception: + LOG.exception('plug external port %s failed', vpn_port) + return None + + ip_cidrs = [] + subnets = [] + for fixed_ip in vpn_port['fixed_ips']: + subnet_id = fixed_ip['subnet_id'] + subnet = self.plugin.get_subnet_info(subnet_id) + net = netaddr.IPNetwork(subnet['cidr']) + ip_cidr = '%s/%s' % (fixed_ip['ip_address'], net.prefixlen) + ip_cidrs.append(ip_cidr) + subnets.append(subnet) + self.driver.init_l3(interface_name, ip_cidrs, + namespace=ns_name) + for subnet in subnets: + self.set_default_route(ns_name, subnet, interface_name) + return interface_name + + def setup_internal(self, process_id, network_details): + vpn_port = network_details["transit_port"] + ns_name = self.get_namespace_name(process_id) + interface_name = self.get_interface_name(vpn_port, 'internal') + + if not ip_lib.ensure_device_is_ready(interface_name, + namespace=ns_name): + try: + self.driver.plug('', + vpn_port['id'], + interface_name, + vpn_port['mac_address'], + namespace=ns_name, + prefix=PORT_PREFIX_INTERNAL) + except Exception: + LOG.exception('plug internal port %s failed', vpn_port['id']) + return None + + ip_cidrs = [] + for fixed_ip in vpn_port['fixed_ips']: + ip_cidr = '%s/%s' % (fixed_ip['ip_address'], 28) + ip_cidrs.append(ip_cidr) + self.driver.init_l3(interface_name, ip_cidrs, + namespace=ns_name) + return interface_name + + +class NamespaceManager(object): + def __init__(self, use_ipv6=False): + self.ip_wrapper_root = ip_lib.IPWrapper() + self.use_ipv6 = use_ipv6 + + def exists(self, name): + return ip_lib.network_namespace_exists(name) + + def create(self, name): + ip_wrapper = self.ip_wrapper_root.ensure_namespace(name) + cmd = ['sysctl', '-w', 'net.ipv4.ip_forward=1'] + ip_wrapper.netns.execute(cmd) + if self.use_ipv6: + cmd = ['sysctl', '-w', 'net.ipv6.conf.all.forwarding=1'] + ip_wrapper.netns.execute(cmd) + + def delete(self, name): + try: + self.ip_wrapper_root.netns.delete(name) + except RuntimeError: + msg = 'Failed trying to delete namespace: %s' + LOG.exception(msg, name) + + +class OvnOpenSwanProcess(ipsec.OpenSwanProcess): + pass + + +class OvnStrongSwanProcess(strongswan_ipsec.StrongSwanProcess): + pass + + +class OvnLibreSwanProcess(libreswan_ipsec.LibreSwanProcess): + pass + + +class IPsecOvnDriverApi(ipsec.IPsecVpnDriverApi): + def __init__(self, topic): + super().__init__(topic) + self.admin_ctx = nctx.get_admin_context_without_session() + + def get_vpn_transit_network_details(self, router_id): + cctxt = self.client.prepare() + return cctxt.call(self.admin_ctx, 'get_vpn_transit_network_details', + router_id=router_id) + + def get_subnet_info(self, subnet_id): + cctxt = self.client.prepare() + return cctxt.call(self.admin_ctx, 'get_subnet_info', + subnet_id=subnet_id) + + +class OvnIPsecDriver(ipsec.IPsecDriver): + + def __init__(self, vpn_service, host): + self.nsmgr = NamespaceManager() + super().__init__(vpn_service, host) + self.agent_rpc = IPsecOvnDriverApi(topics.IPSEC_DRIVER_TOPIC) + self.devmgr = DeviceManager(self.conf, self.host, + self.agent_rpc, self.context) + + get_router_based_iptables_manager = None + + def get_namespace(self, router_id): + """Get namespace for VPN services of router. + + :router_id: router_id + :returns: namespace string. + """ + return self.devmgr.get_namespace_name(router_id) + + def _cleanup_namespace(self, router_id): + ns_name = self.devmgr.get_namespace_name(router_id) + if not self.nsmgr.exists(ns_name): + return + + self.devmgr.del_internal_port(router_id) + self.devmgr.del_external_port(router_id) + self.nsmgr.delete(ns_name) + + def _ensure_namespace(self, router_id, network_details): + ns_name = self.get_namespace(router_id) + if not self.nsmgr.exists(ns_name): + self.nsmgr.create(ns_name) + + # set up vpn external port on provider net + self.devmgr.setup_external(router_id, network_details) + + # set up vpn internal port on transit net + self.devmgr.setup_internal(router_id, network_details) + + return ns_name + + def destroy_process(self, process_id): + LOG.info('process %s is destroyed', process_id) + namespace = self.devmgr.get_namespace_name(process_id) + + # If the namespace exists but the process_id is not in the table + # there may be an active swan process from a previous run of the agent + # which does not have a process object in memory. + # To be able to clean it up we need to create a dummy process object + # here (without a vpnservice), so that destroy_process will stop + # the swan. + if self.nsmgr.exists(namespace) and process_id not in self.processes: + self.ensure_process(process_id) + super().destroy_process(process_id) + self._cleanup_namespace(process_id) + + def create_router(self, router): + pass + + def destroy_router(self, process_id): + pass + + def _update_nat(self, vpnservice, func): + pass + + def _update_route(self, vpnservice, network_details): + router_id = vpnservice['router_id'] + gateway_ip = network_details['transit_gateway_ip'] + namespace = self.devmgr.get_namespace_name(router_id) + + old_local_cidrs = set() + for route in self.devmgr.list_routes(namespace, via=gateway_ip): + old_local_cidrs.add(route['cidr']) + + new_local_cidrs = set() + for ipsec_site_conn in vpnservice['ipsec_site_connections']: + new_local_cidrs.update(ipsec_site_conn['local_cidrs']) + + self.devmgr.delete_routes(namespace, + old_local_cidrs - new_local_cidrs, + gateway_ip) + self.devmgr.add_routes(namespace, + new_local_cidrs - old_local_cidrs, + gateway_ip) + + def _sync_vpn_processes(self, vpnservices, sync_router_ids): + # Ensure the ipsec process is enabled only for + # - the vpn services which are not yet in self.processes + # - vpn services whose router id is in 'sync_router_ids' + for vpnservice in vpnservices: + router_id = vpnservice['router_id'] + if router_id not in self.processes or router_id in sync_router_ids: + net_details = self.agent_rpc.get_vpn_transit_network_details( + router_id) + + self._ensure_namespace(router_id, net_details) + self._update_route(vpnservice, net_details) + process = self.ensure_process(router_id, vpnservice=vpnservice) + process.update() + + def _cleanup_stale_vpn_processes(self, vpn_router_ids): + super()._cleanup_stale_vpn_processes(vpn_router_ids) + # Look for additional namespaces on this node that we don't know + # and that should be deleted + for router_id in self.devmgr.get_existing_process_ids(): + if router_id not in vpn_router_ids: + self.destroy_process(router_id) + + @lockutils.synchronized('vpn-agent', 'neutron-') + def vpnservice_removed_from_agent(self, context, router_id): + # must run under the same lock as sync() + self.destroy_process(router_id) + + def vpnservice_added_to_agent(self, context, router_ids): + routers = [{'id': router_id} for router_id in router_ids] + self.sync(context, routers) + + +class OvnStrongSwanDriver(OvnIPsecDriver): + def create_process(self, process_id, vpnservice, namespace): + return OvnStrongSwanProcess( + self.conf, + process_id, + vpnservice, + namespace) + + +class OvnOpenSwanDriver(OvnIPsecDriver): + def create_process(self, process_id, vpnservice, namespace): + return OvnOpenSwanProcess( + self.conf, + process_id, + vpnservice, + namespace) + + +class OvnLibreSwanDriver(OvnIPsecDriver): + def create_process(self, process_id, vpnservice, namespace): + return OvnLibreSwanProcess( + self.conf, + process_id, + vpnservice, + namespace) diff --git a/neutron_vpnaas/services/vpn/ovn/__init__.py b/neutron_vpnaas/services/vpn/ovn/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/neutron_vpnaas/services/vpn/ovn/agent_monitor.py b/neutron_vpnaas/services/vpn/ovn/agent_monitor.py new file mode 100644 index 000000000..6597f29b0 --- /dev/null +++ b/neutron_vpnaas/services/vpn/ovn/agent_monitor.py @@ -0,0 +1,84 @@ +# Copyright 2023 SysEleven GmbH +# +# 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 neutron.plugins.ml2.drivers.ovn.agent import neutron_agent +from neutron.plugins.ml2.drivers.ovn.mech_driver.ovsdb import ovsdb_monitor +from neutron_lib.plugins import constants as plugin_constants +from neutron_lib.plugins import directory + +from neutron_vpnaas.services.vpn.common import constants + + +class OVNVPNAgent(neutron_agent.NeutronAgent): + agent_type = constants.AGENT_TYPE_VPN + binary = "neutron-ovn-vpn-agent" + + @property + def nb_cfg(self): + return int(self.chassis_private.external_ids.get( + constants.OVN_AGENT_VPN_SB_CFG_KEY, 0)) + + @staticmethod + def id_from_chassis_private(chassis_private): + return chassis_private.external_ids.get( + constants.OVN_AGENT_VPN_ID_KEY) + + @property + def agent_id(self): + return self.id_from_chassis_private(self.chassis_private) + + @property + def description(self): + return self.chassis_private.external_ids.get( + constants.OVN_AGENT_VPN_DESC_KEY, '') + + +class ChassisVPNAgentWriteEvent(ovsdb_monitor.ChassisAgentEvent): + events = (ovsdb_monitor.BaseEvent.ROW_CREATE, + ovsdb_monitor.BaseEvent.ROW_UPDATE) + + @staticmethod + def _vpnagent_nb_cfg(row): + return int( + row.external_ids.get(constants.OVN_AGENT_VPN_SB_CFG_KEY, -1)) + + @staticmethod + def agent_id(row): + return row.external_ids.get(constants.OVN_AGENT_VPN_ID_KEY) + + def match_fn(self, event, row, old=None): + if not self.agent_id(row): + # Don't create a cached object with an agent_id of 'None' + return False + if event == self.ROW_CREATE: + return True + try: + return self._vpnagent_nb_cfg(row) != self._vpnagent_nb_cfg(old) + except (AttributeError, KeyError): + return False + + def run(self, event, row, old): + neutron_agent.AgentCache().update(constants.AGENT_TYPE_VPN, row, + clear_down=True) + + +class OVNVPNAgentMonitor(object): + def watch_agent_events(self): + l3_plugin = directory.get_plugin(plugin_constants.L3) + sb_ovn = l3_plugin._sb_ovn + if sb_ovn: + idl = sb_ovn.ovsdb_connection.idl + if isinstance(idl, ovsdb_monitor.OvnSbIdl): + idl.notify_handler.watch_event( + ChassisVPNAgentWriteEvent(idl.driver)) diff --git a/neutron_vpnaas/services/vpn/ovn_agent.py b/neutron_vpnaas/services/vpn/ovn_agent.py new file mode 100644 index 000000000..1ec331a39 --- /dev/null +++ b/neutron_vpnaas/services/vpn/ovn_agent.py @@ -0,0 +1,71 @@ +# Copyright 2013, Nachi Ueno, NTT I3, Inc. +# Copyright 2023, SysEleven GmbH +# 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. +import sys + +from neutron.common import config as common_config +from neutron.conf.agent import common as agent_config +from neutron.conf.plugins.ml2.drivers.ovn import ovn_conf +from oslo_config import cfg +from oslo_log import log as logging +from oslo_service import service + +from neutron_vpnaas._i18n import _ +from neutron_vpnaas.agent.ovn.vpn import agent + +LOG = logging.getLogger(__name__) + + +VPN_AGENT_OPTS = [ + cfg.MultiStrOpt( + 'vpn_device_driver', + default=['neutron_vpnaas.services.vpn.device_drivers.' + 'ovn_ipsec.OvnStrongSwanDriver'], + sample_default=['neutron_vpnaas.services.vpn.device_drivers.' + 'ovn_ipsec.OvnStrongSwanDriver'], + help=_("The OVN VPN device drivers Neutron will use")), +] + +OVS_OPTS = [ + cfg.StrOpt('ovsdb_connection', + default='unix:/usr/local/var/run/openvswitch/db.sock', + help=_('The connection string for the native OVSDB backend.\n' + 'Use tcp:IP:PORT for TCP connection.\n' + 'Use unix:FILE for unix domain socket connection.')), + cfg.IntOpt('ovsdb_connection_timeout', + default=180, + help=_('Timeout in seconds for the OVSDB ' + 'connection transaction')) +] + + +def register_opts(conf): + common_config.register_common_config_options() + agent_config.register_interface_driver_opts_helper(conf) + agent_config.register_interface_opts(conf) + agent_config.register_availability_zone_opts_helper(conf) + ovn_conf.register_opts() + conf.register_opts(VPN_AGENT_OPTS, 'vpnagent') + conf.register_opts(OVS_OPTS, 'ovs') + + +def main(): + register_opts(cfg.CONF) + common_config.init(sys.argv[1:]) + agent_config.setup_logging() + agent_config.setup_privsep() + + agt = agent.OvnVpnAgent(cfg.CONF) + service.launch(cfg.CONF, agt, restart_method='mutate').wait() diff --git a/neutron_vpnaas/services/vpn/ovn_plugin.py b/neutron_vpnaas/services/vpn/ovn_plugin.py new file mode 100644 index 000000000..5b2a6ea23 --- /dev/null +++ b/neutron_vpnaas/services/vpn/ovn_plugin.py @@ -0,0 +1,76 @@ +# (c) Copyright 2016 IBM Corporation +# (c) Copyright 2023 SysEleven GmbH +# 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 neutron_lib.callbacks import events +from neutron_lib.callbacks import registry +from neutron_lib.callbacks import resources +from oslo_config import cfg +from oslo_utils import importutils + +from neutron_vpnaas.api.rpc.agentnotifiers import vpn_rpc_agent_api as nfy_api +from neutron_vpnaas.db.vpn import vpn_agentschedulers_db as agent_db +from neutron_vpnaas.db.vpn.vpn_db import VPNPluginDb +from neutron_vpnaas.db.vpn import vpn_ext_gw_db +from neutron_vpnaas.services.vpn.common import constants +from neutron_vpnaas.services.vpn.ovn import agent_monitor +from neutron_vpnaas.services.vpn.plugin import VPNDriverPlugin + + +class VPNOVNPlugin(VPNPluginDb, + vpn_ext_gw_db.VPNExtGWPlugin_db, + agent_db.AZVPNAgentSchedulerDbMixin, + agent_monitor.OVNVPNAgentMonitor): + """Implementation of the VPN Service Plugin. + + This class manages the workflow of VPNaaS request/response. + Most DB related works are implemented in class + vpn_db.VPNPluginDb. + """ + def __init__(self): + self.vpn_scheduler = importutils.import_object( + cfg.CONF.vpn_scheduler_driver) + self.add_periodic_vpn_agent_status_check() + self.agent_notifiers[constants.AGENT_TYPE_VPN] = \ + nfy_api.VPNAgentNotifyAPI() + super().__init__() + registry.subscribe(self.post_fork_initialize, + resources.PROCESS, + events.AFTER_INIT) + + def check_router_in_use(self, context, router_id): + pass + + def post_fork_initialize(self, resource, event, trigger, payload=None): + self.watch_agent_events() + + def vpn_router_agent_binding_changed(self, context, router_id, host): + pass + + supported_extension_aliases = ["vpnaas", + "vpn-endpoint-groups", + "service-type", + "vpn-agent-scheduler"] + path_prefix = "/vpn" + + +class VPNOVNDriverPlugin(VPNOVNPlugin, VPNDriverPlugin): + def vpn_router_agent_binding_changed(self, context, router_id, host): + super().vpn_router_agent_binding_changed(context, router_id, host) + filters = {'router_id': [router_id]} + vpnservices = self.get_vpnservices(context, filters=filters) + for vpnservice in vpnservices: + driver = self._get_driver_for_vpnservice(context, vpnservice) + driver.update_port_bindings(context, router_id, host) diff --git a/neutron_vpnaas/services/vpn/plugin.py b/neutron_vpnaas/services/vpn/plugin.py index d23f3868c..6fc9acc84 100644 --- a/neutron_vpnaas/services/vpn/plugin.py +++ b/neutron_vpnaas/services/vpn/plugin.py @@ -75,6 +75,13 @@ class VPNDriverPlugin(VPNPlugin, vpn_db.VPNPluginRpcDbMixin): def _flavors_plugin(self): return directory.get_plugin(constants.FLAVORS) + def start_rpc_listeners(self): + servers = [] + for driver_name, driver in self.drivers.items(): + if hasattr(driver, 'start_rpc_listeners'): + servers.extend(driver.start_rpc_listeners()) + return servers + def _check_orphan_vpnservice_associations(self): context = ncontext.get_admin_context() vpnservices = self.get_vpnservices(context) diff --git a/neutron_vpnaas/services/vpn/service_drivers/ovn_ipsec.py b/neutron_vpnaas/services/vpn/service_drivers/ovn_ipsec.py new file mode 100644 index 000000000..47760f064 --- /dev/null +++ b/neutron_vpnaas/services/vpn/service_drivers/ovn_ipsec.py @@ -0,0 +1,516 @@ +# Copyright 2016, Yi Jing Zhu, IBM. +# Copyright 2023, SysEleven GmbH +# 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. + +import netaddr + +from neutron_lib.api.definitions import portbindings +from neutron_lib.callbacks import events +from neutron_lib.callbacks import registry +from neutron_lib.callbacks import resources +from neutron_lib import constants as lib_constants +from neutron_lib import context as nctx +from neutron_lib.db import api as db_api +from neutron_lib import exceptions as n_exc +from neutron_lib.plugins import constants as plugin_constants +from neutron_lib.plugins import directory +from neutron_lib.plugins import utils as p_utils +from neutron_lib import rpc as n_rpc + +from oslo_config import cfg +from oslo_db import exception as o_exc +from oslo_log import log as logging + +from neutron_vpnaas.db.vpn import vpn_agentschedulers_db as agent_db +from neutron_vpnaas.db.vpn.vpn_ext_gw_db import RouterIsNotVPNExternal +from neutron_vpnaas.db.vpn import vpn_models +from neutron_vpnaas.extensions import vpnaas +from neutron_vpnaas.services.vpn.common import constants as v_constants +from neutron_vpnaas.services.vpn.common import topics +from neutron_vpnaas.services.vpn.service_drivers import base_ipsec + + +LOG = logging.getLogger(__name__) + +IPSEC = 'ipsec' +BASE_IPSEC_VERSION = '1.0' + +TRANSIT_NETWORK_PREFIX = 'vpn-transit-network-' +TRANSIT_SUBNET_PREFIX = 'vpn-transit-subnet-' +TRANSIT_PORT_PREFIX = 'vpn-ns-' +VPN_GW_PORT_PREFIX = 'vpn-gw-' +VPN_TRANSIT_LIP = '169.254.0.1' +VPN_TRANSIT_RIP = '169.254.0.2' +VPN_TRANSIT_CIDR = '169.254.0.0/28' +HIDDEN_PROJECT_ID = '' + + +class IPsecVpnOvnDriverCallBack(base_ipsec.IPsecVpnDriverCallBack): + def __init__(self, driver): + super().__init__(driver) + self.admin_ctx = nctx.get_admin_context() + + @property + def core_plugin(self): + return self.driver.core_plugin + + @property + def service_plugin(self): + return self.driver.service_plugin + + def _get_vpn_gateway(self, context, router_id): + return self.service_plugin.get_vpn_gw_by_router_id(context, router_id) + + def get_vpn_transit_network_details(self, context, router_id): + vpn_gw = self._get_vpn_gateway(context, router_id) + network_id = vpn_gw.gw_port['network_id'] + external_network = self.core_plugin.get_network(context, network_id) + + details = { + 'gw_port': vpn_gw.gw_port, + 'transit_port': vpn_gw.transit_port, + 'transit_gateway_ip': VPN_TRANSIT_LIP, + 'external_network': external_network, + } + return details + + def get_subnet_info(self, context, subnet_id=None): + try: + return self.core_plugin.get_subnet(context, subnet_id) + except n_exc.SubnetNotFound: + return None + + def _get_agent_hosting_vpn_services(self, context, host): + agent = self.service_plugin.get_vpn_agent_on_host(context, host) + if not agent: + return [] + + # We're here because a VPN agent asked for the VPN services it's + # hosting. This means, the agent is alive. This is a chance to + # schedule VPN services of routers that are still unscheduled. + if cfg.CONF.vpn_auto_schedule: + self.service_plugin.auto_schedule_routers(context, agent) + + query = context.session.query(vpn_models.VPNService) + query = query.join(vpn_models.IPsecSiteConnection) + query = query.join(agent_db.RouterVPNAgentBinding, + agent_db.RouterVPNAgentBinding.router_id == + vpn_models.VPNService.router_id) + query = query.filter( + agent_db.RouterVPNAgentBinding.vpn_agent_id == agent['id']) + return query + + +@registry.has_registry_receivers +class BaseOvnIPsecVPNDriver(base_ipsec.BaseIPsecVPNDriver): + def __init__(self, service_plugin): + self._l3_plugin = None + self._core_plugin = None + super().__init__(service_plugin) + + @property + def l3_plugin(self): + if self._l3_plugin is None: + self._l3_plugin = directory.get_plugin(plugin_constants.L3) + return self._l3_plugin + + @property + def core_plugin(self): + if self._core_plugin is None: + self._core_plugin = directory.get_plugin() + return self._core_plugin + + @registry.receives(resources.ROUTER, [events.PRECOMMIT_UPDATE]) + def _handle_router_precommit_update(self, resource, event, trigger, + payload): + """Check that a router update won't remove routes we need for VPN.""" + LOG.debug("Router %s PRECOMMIT_UPDATE event: %s", + payload.resource_id, payload.request_body) + router_id = payload.resource_id + context = payload.context + router_data = payload.request_body + routes_removed = router_data.get('routes_removed') + + if not routes_removed: + return + + removed_cidrs = {r['destination'] for r in routes_removed} + vpn_cidrs = set( + self.service_plugin.get_peer_cidrs_for_router(context, router_id)) + conflict_cidrs = removed_cidrs.intersection(vpn_cidrs) + + if conflict_cidrs: + raise vpnaas.RouteInUseByVPN( + destinations=", ".join(conflict_cidrs)) + + def get_vpn_gw_port_name(self, router_id): + return VPN_GW_PORT_PREFIX + router_id + + def get_vpn_namespace_port_name(self, router_id): + return TRANSIT_PORT_PREFIX + router_id + + def get_transit_network_name(self, router_id): + return TRANSIT_NETWORK_PREFIX + router_id + + def get_transit_subnet_name(self, router_id): + return TRANSIT_SUBNET_PREFIX + router_id + + def make_transit_network(self, router_id, tenant_id, agent_host, + gateway_update): + context = nctx.get_admin_context() + network_data = { + 'tenant_id': HIDDEN_PROJECT_ID, + 'name': self.get_transit_network_name(router_id), + 'admin_state_up': True, + 'shared': False, + } + network = p_utils.create_network(self.core_plugin, context, + {'network': network_data}) + gateway_update['transit_network_id'] = network['id'] + + # The subnet tenant_id must be of the user, otherwise updating the + # router by the user may fail (it needs access to all subnets) + subnet_data = { + 'tenant_id': tenant_id, + 'name': self.get_transit_subnet_name(router_id), + 'gateway_ip': VPN_TRANSIT_LIP, + 'cidr': VPN_TRANSIT_CIDR, + 'network_id': network['id'], + 'ip_version': 4, + 'enable_dhcp': False, + } + subnet = p_utils.create_subnet(self.core_plugin, context, + {'subnet': subnet_data}) + gateway_update['transit_subnet_id'] = subnet['id'] + + self.l3_plugin.add_router_interface(context, router_id, + {'subnet_id': subnet['id']}) + + fixed_ip = {'subnet_id': subnet['id'], 'ip_address': VPN_TRANSIT_RIP} + port_data = { + 'tenant_id': HIDDEN_PROJECT_ID, + 'network_id': network['id'], + 'fixed_ips': [fixed_ip], + 'device_id': subnet['id'], + 'device_owner': v_constants.DEVICE_OWNER_TRANSIT_NETWORK, + 'admin_state_up': True, + portbindings.HOST_ID: agent_host, + 'name': self.get_vpn_namespace_port_name(router_id) + } + port = p_utils.create_port(self.core_plugin, context, + {"port": port_data}) + gateway_update['transit_port_id'] = port['id'] + + def _del_port(self, context, port_id): + try: + self.core_plugin.delete_port(context, port_id, l3_port_check=False) + except n_exc.PortNotFound: + pass + + def _remove_router_interface(self, context, router_id, subnet_id): + try: + self.l3_plugin.remove_router_interface( + context, router_id, {'subnet_id': subnet_id}) + except (n_exc.l3.RouterInterfaceNotFoundForSubnet, + n_exc.SubnetNotFound): + pass + + def _del_subnet(self, context, subnet_id): + try: + self.core_plugin.delete_subnet(context, subnet_id) + except n_exc.SubnetNotFound: + pass + + def _del_network(self, context, network_id): + try: + self.core_plugin.delete_network(context, network_id) + except n_exc.NetworkNotFound: + pass + + def del_transit_network(self, gw): + context = nctx.get_admin_context() + router_id = gw['router_id'] + + port_id = gw.get('transit_port_id') + if port_id: + self._del_port(context, port_id) + + subnet_id = gw.get('transit_subnet_id') + if subnet_id: + self._remove_router_interface(context, router_id, subnet_id) + self._del_subnet(context, subnet_id) + + network_id = gw.get('transit_network_id') + if network_id: + self._del_network(context, network_id) + + def make_gw_port(self, router_id, network_id, agent_host, gateway_update): + context = nctx.get_admin_context() + port_data = {'tenant_id': HIDDEN_PROJECT_ID, + 'network_id': network_id, + 'fixed_ips': lib_constants.ATTR_NOT_SPECIFIED, + 'device_id': router_id, + 'device_owner': v_constants.DEVICE_OWNER_VPN_ROUTER_GW, + 'admin_state_up': True, + portbindings.HOST_ID: agent_host, + 'name': self.get_vpn_gw_port_name(router_id)} + gw_port = p_utils.create_port(self.core_plugin, context.elevated(), + {'port': port_data}) + + if not gw_port['fixed_ips']: + LOG.debug('No IPs available for external network %s', network_id) + gateway_update['gw_port_id'] = gw_port['id'] + + def del_gw_port(self, gateway): + context = nctx.get_admin_context() + port_id = gateway.get('gw_port_id') + if port_id: + self._del_port(context, port_id) + + def _get_peer_cidrs(self, vpnservice): + cidrs = [] + for ipsec_site_connection in vpnservice.ipsec_site_connections: + if ipsec_site_connection.peer_cidrs: + for peer_cidr in ipsec_site_connection.peer_cidrs: + cidrs.append(peer_cidr.cidr) + if ipsec_site_connection.peer_ep_group is not None: + for ep in ipsec_site_connection.peer_ep_group.endpoints: + cidrs.append(ep.endpoint) + return cidrs + + def _routes_update(self, cidrs, nexthop): + routes = [{'destination': cidr, 'nexthop': nexthop} + for cidr in cidrs] + return {'router': {'routes': routes}} + + def _update_static_routes(self, context, ipsec_site_connection): + vpnservice = self.service_plugin.get_vpnservice( + context, ipsec_site_connection['vpnservice_id']) + router_id = vpnservice['router_id'] + gw = self.service_plugin.get_vpn_gw_by_router_id(context, router_id) + + nexthop = gw.transit_port['fixed_ips'][0]['ip_address'] + + router = self.l3_plugin.get_router(context, router_id) + old_routes = router.get('routes', []) + + old_cidrs = set([r['destination'] for r in old_routes + if r['nexthop'] == nexthop]) + new_cidrs = set( + self.service_plugin.get_peer_cidrs_for_router(context, router_id)) + + to_remove = old_cidrs - new_cidrs + if to_remove: + self.l3_plugin.remove_extraroutes(context, router_id, + self._routes_update(to_remove, nexthop)) + + to_add = new_cidrs - old_cidrs + if to_add: + self.l3_plugin.add_extraroutes(context, router_id, + self._routes_update(to_add, nexthop)) + + def _get_gateway_ips(self, router): + """Obtain the IPv4 and/or IPv6 GW IP for the router. + + If there are multiples, (arbitrarily) use the first one. + """ + gateway = self.service_plugin.get_vpn_gw_dict_by_router_id( + nctx.get_admin_context(), + router['id']) + if gateway is None or gateway['external_fixed_ips'] is None: + raise RouterIsNotVPNExternal(router_id=router['id']) + + v4_ip = v6_ip = None + for fixed_ip in gateway['external_fixed_ips']: + addr = fixed_ip['ip_address'] + vers = netaddr.IPAddress(addr).version + if vers == lib_constants.IP_VERSION_4: + if v4_ip is None: + v4_ip = addr + elif v6_ip is None: + v6_ip = addr + return v4_ip, v6_ip + + def _update_gateway(self, context, gateway_id, **kwargs): + gateway = {'gateway': kwargs} + return self.service_plugin.update_gateway(context, gateway_id, gateway) + + @db_api.retry_if_session_inactive() + def _ensure_gateway(self, context, vpnservice): + gw = self.service_plugin.get_vpn_gw_dict_by_router_id( + context, vpnservice['router_id'], refresh=True) + if not gw: + gateway = {'gateway': { + 'router_id': vpnservice['router_id'], + 'tenant_id': vpnservice['tenant_id'], + }} + # create_gateway may raise oslo_db.exception.DBDuplicateEntry + # if someone else created one in the meantime + return self.service_plugin.create_gateway(context, gateway) + + if gw['status'] == lib_constants.ERROR: + raise vpnaas.VPNGatewayInError() + + # Raise an exception if an existing gateway is in status + # PENDING_CREATE or PENDING_DELETE. + # One of the next retries should succeed. + if gw['status'] != lib_constants.ACTIVE: + raise o_exc.RetryRequest(vpnaas.VPNGatewayNotReady()) + return gw + + @db_api.CONTEXT_WRITER + def _setup(self, context, vpnservice_dict): + router_id = vpnservice_dict['router_id'] + agent = self.service_plugin.schedule_router(context, router_id) + if not agent: + raise vpnaas.NoVPNAgentAvailable + agent_host = agent['host'] + + gateway = self._ensure_gateway(context, vpnservice_dict) + + # If the gateway status is ACTIVE the ports have been created already + if gateway['status'] == lib_constants.ACTIVE: + return + + vpnservice = self.service_plugin._get_vpnservice(context, + vpnservice_dict['id']) + network_id = vpnservice.router.gw_port.network_id + gateway_update = {} # keeps track of already-created IDs + try: + self.make_gw_port(router_id, network_id, agent_host, + gateway_update) + self.make_transit_network(router_id, + vpnservice_dict['tenant_id'], + agent_host, + gateway_update) + except Exception: + self._update_gateway(context, gateway['id'], + status=lib_constants.ERROR, + **gateway_update) + raise + + self._update_gateway(context, gateway['id'], + status=lib_constants.ACTIVE, + **gateway_update) + + def _cleanup(self, context, router_id): + gw = self.service_plugin.get_vpn_gw_dict_by_router_id(context, + router_id) + if not gw: + return + self._update_gateway(context, gw['id'], + status=lib_constants.PENDING_DELETE) + try: + self.del_gw_port(gw) + self.del_transit_network(gw) + self.service_plugin.delete_gateway(context, gw['id']) + except Exception: + LOG.exception("Cleanup of VPN gateway for router %s failed.", + router_id) + self._update_gateway(context, gw['id'], + status=lib_constants.ERROR) + raise + + def create_vpnservice(self, context, vpnservice_dict): + try: + self._setup(context, vpnservice_dict) + except Exception: + LOG.exception("Setting up the VPN gateway for router %s failed.", + vpnservice_dict['router_id']) + self.service_plugin.set_vpnservice_status( + context, vpnservice_dict['id'], lib_constants.ERROR, + updated_pending_status=True) + raise + super().create_vpnservice(context, vpnservice_dict) + + def delete_vpnservice(self, context, vpnservice): + router_id = vpnservice['router_id'] + super().delete_vpnservice(context, vpnservice) + services = self.service_plugin.get_vpnservices(context) + router_ids = [s['router_id'] for s in services] + if router_id not in router_ids: + self._cleanup(context, router_id) + + def create_ipsec_site_connection(self, context, ipsec_site_connection): + self._update_static_routes(context, ipsec_site_connection) + super().create_ipsec_site_connection(context, ipsec_site_connection) + + def delete_ipsec_site_connection(self, context, ipsec_site_connection): + self._update_static_routes(context, ipsec_site_connection) + super().delete_ipsec_site_connection(context, ipsec_site_connection) + + def update_ipsec_site_connection( + self, context, old_ipsec_site_connection, ipsec_site_connection): + self._update_static_routes(context, ipsec_site_connection) + super().update_ipsec_site_connection( + context, old_ipsec_site_connection, ipsec_site_connection) + + def _update_port_binding(self, context, port_id, host): + port_data = {'binding:host_id': host} + self.core_plugin.update_port(context, port_id, {'port': port_data}) + + def update_port_bindings(self, context, router_id, host): + gw = self.service_plugin.get_vpn_gw_dict_by_router_id(context, + router_id) + if not gw: + return + port_id = gw.get('gw_port_id') + if port_id: + self._update_port_binding(context, port_id, host) + port_id = gw.get('transit_port_id') + if port_id: + self._update_port_binding(context, port_id, host) + + +class IPsecOvnVpnAgentApi(base_ipsec.IPsecVpnAgentApi): + def _agent_notification(self, context, method, router_id, + version=None, **kwargs): + """Notify update for the agent. + + This method will find where is the router, and + dispatch notification for the agent. + """ + admin_context = context if context.is_admin else context.elevated() + if not version: + version = self.target.version + + vpn_agents = self.driver.service_plugin.get_vpn_agents_hosting_routers( + admin_context, [router_id], active=True) + + for vpn_agent in vpn_agents: + LOG.debug('Notify agent at %(topic)s.%(host)s the message ' + '%(method)s %(args)s', + {'topic': self.topic, + 'host': vpn_agent['host'], + 'method': method, + 'args': kwargs}) + cctxt = self.client.prepare(server=vpn_agent['host'], + version=version) + cctxt.cast(context, method, **kwargs) + + +class IPsecOvnVPNDriver(BaseOvnIPsecVPNDriver): + """VPN Service Driver class for IPsec.""" + + def create_rpc_conn(self): + self.agent_rpc = IPsecOvnVpnAgentApi( + topics.IPSEC_AGENT_TOPIC, BASE_IPSEC_VERSION, self) + + def start_rpc_listeners(self): + self.endpoints = [IPsecVpnOvnDriverCallBack(self)] + self.conn = n_rpc.Connection() + self.conn.create_consumer( + topics.IPSEC_DRIVER_TOPIC, self.endpoints, fanout=False) + return self.conn.consume_in_threads() diff --git a/neutron_vpnaas/tests/functional/common/ovn_base.py b/neutron_vpnaas/tests/functional/common/ovn_base.py new file mode 100644 index 000000000..04cf5e042 --- /dev/null +++ b/neutron_vpnaas/tests/functional/common/ovn_base.py @@ -0,0 +1,491 @@ +# 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 + +import netaddr +from neutron.agent.linux import ip_lib +from neutron.common import config as common_config +from neutron.common.ovn import constants as ovn_const +from neutron.conf.agent import common as agent_conf +from neutron.conf import common as common_conf +from neutron.conf.plugins.ml2.drivers.ovn import ovn_conf +from neutron.conf.plugins.ml2.drivers import ovs_conf +from neutron.tests.common import net_helpers +from neutron.tests.functional import base +from neutron_lib import constants as lib_constants +from neutron_lib.plugins import constants as plugin_constants +from neutron_lib.plugins import directory +from neutron_lib.utils import helpers +from oslo_config import cfg +from ovsdbapp.backend.ovs_idl import event + +from neutron_vpnaas.agent.ovn.vpn import agent +from neutron_vpnaas.agent.ovn.vpn import ovsdb +from neutron_vpnaas.services.vpn.common import constants as vpn_const +from neutron_vpnaas.services.vpn.device_drivers import ipsec +from neutron_vpnaas.services.vpn import ovn_agent +from neutron_vpnaas.services.vpn.service_drivers import ovn_ipsec + + +OVS_INTERFACE_DRIVER = 'neutron.agent.linux.interface.OVSInterfaceDriver' +IPSEC_SERVICE_PROVIDER = ('VPN:ovn:neutron_vpnaas.services.vpn.' + 'service_drivers.ovn_ipsec.IPsecOvnVPNDriver:' + 'default') +VPN_PLUGIN = 'neutron_vpnaas.services.vpn.ovn_plugin.VPNOVNDriverPlugin' + +PUBLIC_NET = netaddr.IPNetwork('19.4.4.0/24') +LOCAL_NETS = list(netaddr.IPNetwork('10.0.0.0/16').subnet(24)) +PEER_NET = netaddr.IPNetwork('10.1.0.0/16') +PEER_ADDR = '19.4.5.6' + + +class VPNAgentHealthEvent(event.WaitEvent): + event_name = 'VPNAgentHealthEvent' + + def __init__(self, chassis, sb_cfg, table, timeout=5): + self.chassis = chassis + self.sb_cfg = sb_cfg + super().__init__( + (self.ROW_UPDATE,), table, (('name', '=', self.chassis),), + timeout=timeout) + + def matches(self, event, row, old=None): + if not super().matches(event, row, old): + return False + return int(row.external_ids.get( + vpn_const.OVN_AGENT_VPN_SB_CFG_KEY, 0)) >= self.sb_cfg + + +class OvnSiteInfo: + def __init__(self, parent, index, ext_net, ext_sub): + self.ext_net = ext_net + self.ext_sub = ext_sub + self.parent = parent + self.context = parent.context + self.fmt = parent.fmt + self.index = index + + def create_base(self): + router_data = { + 'name': 'r%d' % self.index, + 'admin_state_up': True, + 'tenant_id': self.parent._tenant_id, + 'external_gateway_info': { + 'enable_snat': True, + 'network_id': self.ext_net['id'], + 'external_fixed_ips': [ + {'ip_address': str(PUBLIC_NET[4 + 2 * self.index]), + 'subnet_id': self.ext_sub['id']} + ] + } + } + self.router = self.parent.l3_plugin.create_router( + self.context, {'router': router_data}) + + # local subnet + private_net = LOCAL_NETS[self.index] + self.local_cidr = str(private_net) + + net = self.parent._make_network(self.fmt, 'local%d' % self.index, True) + self.local_net = net['network'] + sub = self.parent._make_subnet(self.fmt, net, private_net[1], + self.local_cidr, enable_dhcp=False) + self.local_sub = sub['subnet'] + interface_info = {'subnet_id': self.local_sub['id']} + self.parent.l3_plugin.add_router_interface( + self.context, self.router['id'], interface_info) + + def create_vpnservice(self): + plugin = self.parent.vpn_plugin + data = { + 'tenant_id': self.parent._tenant_id, + 'name': 'my-service', + 'description': 'new service', + 'subnet_id': self.local_sub['id'], + 'router_id': self.router['id'], + 'flavor_id': None, + 'admin_state_up': True, + } + self.vpnservice = plugin.create_vpnservice(self.context, + {'vpnservice': data}) + self.local_addr = self.vpnservice['external_v4_ip'] + + data = { + 'tenant_id': self.parent._tenant_id, + 'name': 'ikepolicy%d' % self.index, + 'description': '', + 'auth_algorithm': 'sha1', + 'encryption_algorithm': 'aes-128', + 'phase1_negotiation_mode': 'main', + 'ike_version': 'v1', + 'pfs': 'group5', + 'lifetime': {'units': 'seconds', 'value': 3600}, + } + self.ikepolicy = plugin.create_ikepolicy(self.context, + {'ikepolicy': data}) + + data = { + 'tenant_id': self.parent._tenant_id, + 'name': 'ipsecpolicy%d' % self.index, + 'description': '', + 'transform_protocol': 'esp', + 'auth_algorithm': 'sha1', + 'encryption_algorithm': 'aes-128', + 'encapsulation_mode': 'tunnel', + 'pfs': 'group5', + 'lifetime': {'units': 'seconds', 'value': 3600}, + } + self.ipsecpolicy = plugin.create_ipsecpolicy(self.context, + {'ipsecpolicy': data}) + + def create_site_connection(self, peer_addr, peer_cidr): + data = { + 'tenant_id': self.parent._tenant_id, + 'name': 'conn%d' % self.index, + 'description': '', + 'local_id': self.local_addr, + 'peer_address': peer_addr, + 'peer_id': peer_addr, + 'peer_cidrs': [peer_cidr], + 'mtu': 1500, + 'initiator': 'bi-directional', + 'auth_mode': 'psk', + 'psk': 'secret', + 'dpd': { + 'action': 'hold', + 'interval': 30, + 'timeout': 120, + }, + 'admin_state_up': True, + 'vpnservice_id': self.vpnservice['id'], + 'ikepolicy_id': self.ikepolicy['id'], + 'ipsecpolicy_id': self.ipsecpolicy['id'], + 'local_ep_group_id': None, + 'peer_ep_group_id': None, + } + self.siteconn = self.parent.vpn_plugin.create_ipsec_site_connection( + self.context, {'ipsec_site_connection': data}) + + +class TestOvnVPNAgentBase(base.TestOVNFunctionalBase): + FAKE_CHASSIS_HOST = 'ovn-host-fake' + + def setUp(self): + cfg.CONF.set_override('service_provider', [IPSEC_SERVICE_PROVIDER], + group='service_providers') + service_plugins = {'vpnaas_plugin': VPN_PLUGIN} + super().setUp(service_plugins=service_plugins) + common_config.register_common_config_options() + + self.mock_ovsdb_idl = mock.Mock() + mock_instance = mock.Mock() + mock_instance.start.return_value = self.mock_ovsdb_idl + mock_ovs_idl = mock.patch.object(ovsdb, 'VPNAgentOvsIdl').start() + mock_ovs_idl.return_value = mock_instance + + self.vpn_plugin = directory.get_plugin(plugin_constants.VPN) + # normally called in post_for_initialize + self.vpn_plugin.watch_agent_events() + self.vpn_service_driver = self.vpn_plugin.drivers['ovn'] + + self.handler = self.sb_api.idl.notify_handler + self.agent = self._start_vpn_agent() + self.agent_driver = self.agent.device_drivers[0] + + def _start_vpn_agent(self): + # Set up a ConfigOpts separate to cfg.CONF in order to avoid conflicts + # with other tests. + # The OVN VPN agent registers a different variant of + # vpnagent.vpn_device_drivers than the L3 agent extension. + conf = agent_conf.setup_conf() + conf.register_opts(ovn_conf.ovn_opts, group='ovn') + conf.register_opts(ipsec.ipsec_opts, 'ipsec') + common_conf.register_core_common_config_opts(conf) + ovs_conf.register_ovs_opts(conf) + ovn_agent.register_opts(conf) + agent_conf.register_process_monitor_opts(conf) + agent_conf.setup_privsep() + + conf.set_override('state_path', self.get_default_temp_dir().path) + conf.set_override('interface_driver', OVS_INTERFACE_DRIVER) + conf.set_override('vpn_device_driver', [self.VPN_DEVICE_DRIVER], + group='vpnagent') + + ovn_sb_db = self.ovsdb_server_mgr.get_ovsdb_connection_path('sb') + conf.set_override('ovn_sb_connection', ovn_sb_db, group='ovn') + + self.chassis_name = self.add_fake_chassis(self.FAKE_CHASSIS_HOST) + mock.patch.object(agent.OvnVpnAgent, + '_get_own_chassis_name', + return_value=self.chassis_name).start() + conf.set_override('host', self.FAKE_CHASSIS_HOST) + + self.br_int = self.useFixture(net_helpers.OVSBridgeFixture()).bridge + conf.set_override('integration_bridge', self.br_int.br_name, 'OVS') + + # name prefix for namespaces managed by vpn agent + # will be patched into device driver to make sure concurrent + # tests don't interfere with each other + # (a vpn agent will normally remove all unknown qvpn- namespaces) + self.ns_prefix = 'qvpn-test-%s-' % helpers.get_random_string(8) + + agt = agent.OvnVpnAgent(conf) + driver = agt.device_drivers[0] + driver.agent_rpc = mock.Mock() + # let initial sync get an empty list of vpnservices + driver.agent_rpc.get_vpn_services_on_host.return_value = [] + driver.devmgr.plugin = driver.agent_rpc + driver.devmgr.OVN_NS_PREFIX = self.ns_prefix + + agt.start() + self.addCleanup(agt.ovs_idl.ovsdb_connection.stop) + self.addCleanup(agt.sb_idl.ovsdb_connection.stop) + # let agent remove remaining vpn namespaces in cleanup + self.addCleanup(driver._cleanup_stale_vpn_processes, []) + + return agt + + @property + def agent_chassis_table(self): + if self.agent.has_chassis_private: + return 'Chassis_Private' + return 'Chassis' + + def _make_ext_network(self): + network = self._make_network( + self.fmt, 'external-net', True, as_admin=True, + arg_list=('router:external', + 'provider:network_type', + 'provider:physical_network'), + **{'router:external': True, + 'provider:network_type': 'flat', + 'provider:physical_network': 'public'}) + + pools = [{'start': PUBLIC_NET[2], 'end': PUBLIC_NET[253]}] + gateway = PUBLIC_NET[1] + cidr = str(PUBLIC_NET) + subnet = self._make_subnet(self.fmt, network, gateway, cidr, + allocation_pools=pools, + enable_dhcp=False) + return network['network'], subnet['subnet'] + + def _find_lswitch_by_neutron_name(self, name): + for row in self.nb_api._tables['Logical_Switch'].rows.values(): + if (row.external_ids.get( + ovn_const.OVN_NETWORK_NAME_EXT_ID_KEY) == name): + return row + + def _find_transit_lswitch(self, router_id): + name = ovn_ipsec.TRANSIT_NETWORK_PREFIX + router_id + return self._find_lswitch_by_neutron_name(name) + + def _match_extids(self, row, expected): + for key, value in expected.items(): + if row.external_ids.get(key) != value: + return False + return True + + def _find_transit_ns_port(self, router_id, ports): + name = ovn_ipsec.TRANSIT_PORT_PREFIX + router_id + extids = {ovn_const.OVN_PORT_NAME_EXT_ID_KEY: name} + + for row in ports: + if self._match_extids(row, extids): + return row + + def _find_transit_router_port(self, router_id, network_name, ports): + extids = { + ovn_const.OVN_DEVID_EXT_ID_KEY: router_id, + ovn_const.OVN_DEVICE_OWNER_EXT_ID_KEY: 'network:router_interface', + ovn_const.OVN_NETWORK_NAME_EXT_ID_KEY: network_name, + } + for row in ports: + if self._match_extids(row, extids): + return row + + def _find_vpn_gw_port(self, router_id, ports): + name = ovn_ipsec.VPN_GW_PORT_PREFIX + router_id + extids = {ovn_const.OVN_PORT_NAME_EXT_ID_KEY: name} + + for row in ports: + if self._match_extids(row, extids): + return row + + def _find_lrouter_by_neutron_id(self, router_id): + for row in self.nb_api._tables['Logical_Router'].rows.values(): + if row.name == "neutron-" + router_id: + return row + + def test_agent(self): + chassis_row = self.sb_api.db_find( + self.agent_chassis_table, + ('name', '=', self.chassis_name)).execute( + check_error=True)[0] + + # Assert that, prior to creating a resource the VPN agent + # didn't populate the external_ids from the Chassis + self.assertNotIn(vpn_const.OVN_AGENT_VPN_SB_CFG_KEY, + chassis_row['external_ids']) + + # Let's list the agents to force the nb_cfg to be bumped on NB + # db, which will automatically increment the nb_cfg counter on + # NB_Global and make ovn-controller copy it over to SB_Global. Upon + # this event, VPN agent will update the external_ids on its + # Chassis row to signal that it's healthy. + + row_event = VPNAgentHealthEvent(self.chassis_name, 1, + self.agent_chassis_table) + self.handler.watch_event(row_event) + self.new_list_request('agents').get_response(self.api) + + # If we do not time out waiting for the event, then we are assured + # that the VPN agent has populated the external_ids from the + # chassis with the nb_cfg, 1 revisions when listing the agents. + self.assertTrue(row_event.wait()) + + def test_service(self): + r = self.new_list_request('agents').get_response(self.api) + ext_net, ext_sub = self._make_ext_network() + + server = ovn_ipsec.IPsecVpnOvnDriverCallBack(self.vpn_service_driver) + + # Mock the controller side RPC client (prepare and cast) + # to be able to check that "vpnservice_updated" will be called + prepare_mock = mock.Mock() + prepared_mock = mock.Mock() + self.vpn_service_driver.agent_rpc.client.prepare = prepare_mock + prepare_mock.return_value = prepared_mock + + # Create a site (router, network, subnet, vpnservice, site conn) + site = OvnSiteInfo(self, 1, ext_net, ext_sub) + site.create_base() + site.create_vpnservice() + site.create_site_connection(PEER_ADDR, str(PEER_NET)) + + # Check that the vpnservice_updated RPC was triggered towards + # the agent + prepare_mock.assert_called_once_with( + server=self.FAKE_CHASSIS_HOST, + version=self.vpn_service_driver.agent_rpc.target.version) + prepared_mock.cast.assert_called_once_with( + self.context, 'vpnservice_updated', + router={'id': site.router['id']}) + + # Mock the agent->controller RPCs. Let them return data from the + # actual VPN plugin + def get_vpn_services_on_host(ctx, host): + r = server.get_vpn_services_on_host(self.context, host) + return r + + def get_vpn_transit_network_details(router_id): + return server.get_vpn_transit_network_details( + self.context, router_id) + + def get_subnet_info(subnet_id): + return server.get_subnet_info(self.context, subnet_id) + + r = self.agent_driver.agent_rpc + r.get_vpn_services_on_host.side_effect = get_vpn_services_on_host + r.get_vpn_transit_network_details.side_effect = \ + get_vpn_transit_network_details + r.get_subnet_info.side_effect = get_subnet_info + + # Call the agent's vpnservice_updated as if it was coming from + # the controller. + for driver in self.agent.device_drivers: + driver.vpnservice_updated(driver.context, + router={'id': site.router['id']}) + + # Check that transit network and VPN gateway port are set up correctly + # - transit network exists + # - router port in transit network exists + # - transit network port to be bound to chassis exists and + # host is assigned + # - VPN gateway port exists and host is assigned + # - static route exists towards peer CIDR + + # expect transit network in NB + transit_row = self._find_transit_lswitch(site.router['id']) + self.assertIsNotNone(transit_row) + + # check the transit network router port exists + transit_router_port = self._find_transit_router_port( + site.router['id'], transit_row.name, transit_row.ports) + self.assertIsNotNone(transit_router_port) + + # check that the namespace port in the transit network exists + transit_ns_port = self._find_transit_ns_port(site.router['id'], + transit_row.ports) + self.assertIsNotNone(transit_ns_port) + + # check that the port has the requested-host option + requested_host = transit_ns_port.options.get( + ovn_const.LSP_OPTIONS_REQUESTED_CHASSIS_KEY) + self.assertEqual(requested_host, self.FAKE_CHASSIS_HOST) + + # get vpn gateway port via external network lswitch + ext_row = self._find_lswitch_by_neutron_name("external-net") + self.assertIsNotNone(ext_row) + + vpn_gw_port = self._find_vpn_gw_port(site.router['id'], ext_row.ports) + self.assertIsNotNone(vpn_gw_port) + # check that vpn gateway port has the requested-host option + requested_host = vpn_gw_port.options.get( + ovn_const.LSP_OPTIONS_REQUESTED_CHASSIS_KEY) + self.assertEqual(requested_host, self.FAKE_CHASSIS_HOST) + + # check that static route towards peer cidr is set + router_row = self._find_lrouter_by_neutron_id(site.router['id']) + self.assertIsNotNone(router_row) + for r in router_row.static_routes: + if r.ip_prefix == str(PEER_NET): + route = r + break + else: + route = None + + self.assertIsNotNone(route) + self.assertEqual(route.nexthop, ovn_ipsec.VPN_TRANSIT_RIP) + + # Check agent side + # - network namespace + # - routes towards transit network's gateway IP + # - devices and their IP addresses in the namespace + ns_name = self.ns_prefix + site.router['id'] + devlen = lib_constants.LINUX_DEV_LEN + transit_dev = ('vr' + transit_ns_port.name)[:devlen] + gw_dev = ('vg' + vpn_gw_port.name)[:devlen] + self.assertTrue(ip_lib.network_namespace_exists(ns_name)) + device = ip_lib.IPDevice(None, namespace=ns_name) + routes = device.route.list_routes(lib_constants.IP_VERSION_4, + proto='static', + via=ovn_ipsec.VPN_TRANSIT_LIP) + self.assertEqual(len(routes), 1) + self.assertEqual(routes[0]['via'], ovn_ipsec.VPN_TRANSIT_LIP) + self.assertEqual(routes[0]['cidr'], site.local_cidr) + self.assertEqual(routes[0]['device'], transit_dev) + + # check addresses in namespace + addrs = device.addr.list(ip_version=lib_constants.IP_VERSION_4) + addrs_dict = {a['name']: a for a in addrs} + self.assertIn(transit_dev, addrs_dict) + self.assertEqual( + addrs_dict[transit_dev]['cidr'], + transit_ns_port.external_ids[ovn_const.OVN_CIDRS_EXT_ID_KEY]) + + self.assertIn(gw_dev, addrs_dict) + self.assertEqual( + addrs_dict[gw_dev]['cidr'], + vpn_gw_port.external_ids[ovn_const.OVN_CIDRS_EXT_ID_KEY]) diff --git a/neutron_vpnaas/tests/functional/openswan/test_ovn_openswan.py b/neutron_vpnaas/tests/functional/openswan/test_ovn_openswan.py new file mode 100644 index 000000000..7e19ded60 --- /dev/null +++ b/neutron_vpnaas/tests/functional/openswan/test_ovn_openswan.py @@ -0,0 +1,20 @@ +# Copyright 2023 SysEleven GmbH +# +# 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 neutron_vpnaas.tests.functional.common import test_ovn + + +class TestOvnOpenSwan(test_ovn.TestOvnVPNAgentBase): + VPN_DEVICE_DRIVER = ('neutron_vpnaas.services.vpn.device_drivers.' + 'ovn_ipsec.OvnOpenSwanDriver') diff --git a/neutron_vpnaas/tests/functional/strongswan/test_ovn_strongswan.py b/neutron_vpnaas/tests/functional/strongswan/test_ovn_strongswan.py new file mode 100644 index 000000000..33e5ca6b4 --- /dev/null +++ b/neutron_vpnaas/tests/functional/strongswan/test_ovn_strongswan.py @@ -0,0 +1,20 @@ +# Copyright 2023 SysEleven GmbH +# +# 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 neutron_vpnaas.tests.functional.common import ovn_base + + +class TestOvnStrongSwan(ovn_base.TestOvnVPNAgentBase): + VPN_DEVICE_DRIVER = ('neutron_vpnaas.services.vpn.device_drivers.' + 'ovn_ipsec.OvnStrongSwanDriver') diff --git a/neutron_vpnaas/tests/unit/db/vpn/test_vpn_agentschedulers_db.py b/neutron_vpnaas/tests/unit/db/vpn/test_vpn_agentschedulers_db.py new file mode 100644 index 000000000..cac246d19 --- /dev/null +++ b/neutron_vpnaas/tests/unit/db/vpn/test_vpn_agentschedulers_db.py @@ -0,0 +1,525 @@ +# Copyright 2023 SysEleven GmbH. +# +# 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.api import extensions +from neutron.common.ovn import constants as ovn_constants +from neutron import policy +from neutron.tests.common import helpers +from neutron.tests.unit.api import test_extensions +from neutron.tests.unit.db import test_db_base_plugin_v2 as test_plugin +from neutron.tests.unit.extensions import test_l3 +from neutron.tests.unit import testlib_api +from neutron import wsgi +from neutron_lib import context +from neutron_lib import exceptions as n_exc +from neutron_lib.plugins import constants as plugin_constants +from neutron_lib.plugins import directory +from neutron_lib import rpc as n_rpc +from oslo_db import exception as db_exc +import oslo_messaging +from oslo_utils import uuidutils +from sqlalchemy import orm +from webob import exc + +from neutron_vpnaas.api.rpc.agentnotifiers import vpn_rpc_agent_api +from neutron_vpnaas.extensions import vpn_agentschedulers +from neutron_vpnaas.services.vpn.common import constants +from neutron_vpnaas.tests.unit.db.vpn import test_vpn_db + + +VPN_HOSTA = "host-1" +VPN_HOSTB = "host-2" + + +class VPNAgentSchedulerTestMixIn(object): + def _request_list(self, path, admin_context=True, + expected_code=exc.HTTPOk.code): + req = self._path_req(path, admin_context=admin_context) + res = req.get_response(self.ext_api) + self.assertEqual(expected_code, res.status_int) + return self.deserialize(self.fmt, res) + + def _path_req(self, path, method='GET', data=None, + query_string=None, + admin_context=True): + content_type = 'application/%s' % self.fmt + body = None + if data is not None: # empty dict is valid + body = wsgi.Serializer().serialize(data, content_type) + if admin_context: + return testlib_api.create_request( + path, body, content_type, method, query_string=query_string) + else: + return testlib_api.create_request( + path, body, content_type, method, query_string=query_string, + context=context.Context('', 'tenant_id')) + + def _path_create_request(self, path, data, admin_context=True): + return self._path_req(path, method='POST', data=data, + admin_context=admin_context) + + def _path_show_request(self, path, admin_context=True): + return self._path_req(path, admin_context=admin_context) + + def _path_delete_request(self, path, admin_context=True): + return self._path_req(path, method='DELETE', + admin_context=admin_context) + + def _path_update_request(self, path, data, admin_context=True): + return self._path_req(path, method='PUT', data=data, + admin_context=admin_context) + + def _list_routers_hosted_by_vpn_agent(self, agent_id, + expected_code=exc.HTTPOk.code, + admin_context=True): + path = "/agents/%s/%s.%s" % (agent_id, + vpn_agentschedulers.VPN_ROUTERS, + self.fmt) + return self._request_list(path, expected_code=expected_code, + admin_context=admin_context) + + def _add_router_to_vpn_agent(self, id, router_id, + expected_code=exc.HTTPCreated.code, + admin_context=True): + path = "/agents/%s/%s.%s" % (id, + vpn_agentschedulers.VPN_ROUTERS, + self.fmt) + req = self._path_create_request(path, + {'router_id': router_id}, + admin_context=admin_context) + res = req.get_response(self.ext_api) + self.assertEqual(expected_code, res.status_int) + + def _list_vpn_agents_hosting_router(self, router_id, + expected_code=exc.HTTPOk.code, + admin_context=True): + path = "/routers/%s/%s.%s" % (router_id, + vpn_agentschedulers.VPN_AGENTS, + self.fmt) + return self._request_list(path, expected_code=expected_code, + admin_context=admin_context) + + def _remove_router_from_vpn_agent(self, id, router_id, + expected_code=exc.HTTPNoContent.code, + admin_context=True): + path = "/agents/%s/%s/%s.%s" % (id, + vpn_agentschedulers.VPN_ROUTERS, + router_id, + self.fmt) + req = self._path_delete_request(path, admin_context=admin_context) + res = req.get_response(self.ext_api) + self.assertEqual(expected_code, res.status_int) + + +class VPNAgentSchedulerTestCaseBase(test_vpn_db.VPNTestMixin, + test_l3.L3NatTestCaseMixin, + VPNAgentSchedulerTestMixIn, + test_plugin.NeutronDbPluginV2TestCase): + fmt = 'json' + + def setUp(self): + # NOTE(ivasilevskaya) mocking this way allows some control over mocked + # client like further method mocking with asserting calls + self.client_mock = mock.MagicMock(name="mocked client") + mock.patch.object( + n_rpc, 'get_client').start().return_value = self.client_mock + + service_plugins = { + 'vpnaas_plugin': 'neutron_vpnaas.services.vpn.ovn_plugin.' + 'VPNOVNPlugin'} + plugin_str = 'neutron.tests.unit.extensions.test_l3.TestL3NatIntPlugin' + super().setUp(plugin_str, service_plugins=service_plugins) + + ext_mgr = extensions.PluginAwareExtensionManager.get_instance() + self.ext_api = test_extensions.setup_extensions_middleware(ext_mgr) + self.adminContext = context.get_admin_context() + + self.core_plugin = directory.get_plugin() + self.core_plugin.get_agents = \ + mock.MagicMock(side_effect=self._get_agents) + self.core_plugin.get_agent = \ + mock.MagicMock(side_effect=self._get_agent) + self._agents = {} + self._vpn_agents_by_host = {} + + self.service_plugin = directory.get_plugin(plugin_constants.VPN) + policy.init() + + def _get_agents(self, context, filters=None): + if not filters: + return self._agents.values() + + agents = [] + for agent in self._agents.values(): + for key, values in filters.items(): + if agent[key] not in values: + break + else: + agents.append(agent) + return agents + + def _get_agent(self, context, agent_id): + try: + return self._agents[agent_id] + except KeyError: + raise n_exc.agent.AgentNotFound(id=agent_id) + + def _get_any_metadata_agent_id(self): + for agent in self._agents.values(): + if agent['agent_type'] == ovn_constants.OVN_METADATA_AGENT: + return agent['id'] + + def _take_down_vpn_agent(self, host): + self._vpn_agents_by_host[host]['alive'] = False + + def _get_another_agent_host(self, host): + for agent in self._vpn_agents_by_host.values(): + if agent['host'] != host: + return agent['host'] + + def _register_agent_states(self): + self._register_vpn_agent(host=VPN_HOSTA) + self._register_vpn_agent(host=VPN_HOSTB) + self._register_metadata_agent(host=VPN_HOSTA) + self._register_metadata_agent(host=VPN_HOSTB) + + def _register_vpn_agent(self, host=None): + agent = { + 'id': uuidutils.generate_uuid(), + 'binary': "neutron-ovn-vpn-agent", + 'host': host, + 'availability_zone': helpers.DEFAULT_AZ, + 'topic': 'n/a', + 'configurations': {}, + 'start_flag': True, + 'agent_type': constants.AGENT_TYPE_VPN, + 'alive': True, + 'admin_state_up': True} + self._agents[agent['id']] = agent + self._vpn_agents_by_host[host] = agent + + def _register_metadata_agent(self, host=None): + agent = { + 'id': uuidutils.generate_uuid(), + 'binary': "neutron-ovn-metadata-agent", + 'host': host, + 'availability_zone': helpers.DEFAULT_AZ, + 'topic': 'n/a', + 'configurations': {}, + 'start_flag': True, + 'agent_type': ovn_constants.OVN_METADATA_AGENT, + 'alive': True, + 'admin_state_up': True} + self._agents[agent['id']] = agent + + +class VPNAgentSchedulerTestCase(VPNAgentSchedulerTestCaseBase): + def _take_down_agent_and_run_reschedule(self, host): + self._take_down_vpn_agent(host) + plugin = directory.get_plugin(plugin_constants.VPN) + plugin.reschedule_vpnservices_from_down_agents() + + def _get_agent_host_by_router(self, router_id): + agents = self._list_vpn_agents_hosting_router(router_id) + return agents['agents'][0]['host'] + + def test_schedule_router(self): + self._register_agent_states() + with self.router() as router: + router_id = router['router']['id'] + self.service_plugin.schedule_router(self.adminContext, router_id) + host = self._get_agent_host_by_router(router_id) + + self.assertIn(host, (VPN_HOSTA, VPN_HOSTB)) + + def test_router_rescheduler_catches_rpc_db_and_reschedule_exceptions(self): + self._register_agent_states() + agent_a_id = self._vpn_agents_by_host[VPN_HOSTA]['id'] + + with self.router() as router: + router_id = router['router']['id'] + self._add_router_to_vpn_agent(agent_a_id, router_id) + mock.patch.object( + self.service_plugin, 'reschedule_router', + side_effect=[ + db_exc.DBError(), oslo_messaging.RemoteError(), + vpn_agentschedulers.RouterReschedulingFailed( + router_id='f'), + ValueError('this raises'), + Exception() + ]).start() + self._take_down_agent_and_run_reschedule(VPN_HOSTA) # DBError + self._take_down_agent_and_run_reschedule(VPN_HOSTA) # RemoteError + self._take_down_agent_and_run_reschedule(VPN_HOSTA) # schedule err + self._take_down_agent_and_run_reschedule(VPN_HOSTA) # Value error + self._take_down_agent_and_run_reschedule(VPN_HOSTA) # Exception + + def test_router_rescheduler_catches_exceptions_on_fetching_bindings(self): + with mock.patch('neutron_lib.context.get_admin_context') as get_ctx: + mock_ctx = mock.Mock() + get_ctx.return_value = mock_ctx + mock_ctx.session.query.side_effect = db_exc.DBError() + + # check that no exception is raised + self.service_plugin.reschedule_vpnservices_from_down_agents() + + def test_router_rescheduler_iterates_after_reschedule_failure(self): + self._register_agent_states() + agent_a = self.service_plugin.get_vpn_agent_on_host( + self.adminContext, VPN_HOSTA) + + with self.vpnservice() as s1, self.vpnservice() as s2: + # schedule the services to agent A + self.service_plugin.auto_schedule_routers( + self.adminContext, agent_a) + + rs_mock = mock.patch.object( + self.service_plugin, 'reschedule_router', + side_effect=vpn_agentschedulers.RouterReschedulingFailed( + router_id='f'), + ).start() + self._take_down_agent_and_run_reschedule(VPN_HOSTA) + # make sure both had a reschedule attempt even though first failed + router_id_1 = s1['vpnservice']['router_id'] + router_id_2 = s2['vpnservice']['router_id'] + rs_mock.assert_has_calls( + [mock.call(mock.ANY, router_id_1, agent_a), + mock.call(mock.ANY, router_id_2, agent_a)], + any_order=True) + + def test_router_is_not_rescheduled_from_alive_agent(self): + self._register_agent_states() + agent_a_id = self._vpn_agents_by_host[VPN_HOSTA]['id'] + + with self.router() as router: + router_id = router['router']['id'] + self._add_router_to_vpn_agent(agent_a_id, router_id) + + patch_func_str = ('neutron_vpnaas.db.vpn.vpn_agentschedulers_db.' + 'VPNAgentSchedulerDbMixin.reschedule_router') + with mock.patch(patch_func_str) as rr: + # take down the unrelated agent and run reschedule check + self._take_down_agent_and_run_reschedule(VPN_HOSTB) + self.assertFalse(rr.called) + + def test_router_reschedule_from_dead_agent(self): + self._register_agent_states() + agent_a_id = self._vpn_agents_by_host[VPN_HOSTA]['id'] + + with self.router() as router: + router_id = router['router']['id'] + self._add_router_to_vpn_agent(agent_a_id, router_id) + host_before = self._get_agent_host_by_router(router_id) + + self._take_down_agent_and_run_reschedule(VPN_HOSTA) + host_after = self._get_agent_host_by_router(router_id) + + self.assertEqual(VPN_HOSTA, host_before) + self.assertEqual(VPN_HOSTB, host_after) + + def test_router_reschedule_succeeded_after_failed_notification(self): + self._register_agent_states() + agent_a = self.service_plugin.get_vpn_agent_on_host( + self.adminContext, VPN_HOSTA) + + with self.vpnservice() as service: + # schedule the vpn routers to agent A + self.service_plugin.auto_schedule_routers( + self.adminContext, agent_a) + ctxt_mock = mock.MagicMock() + call_mock = mock.MagicMock( + side_effect=[oslo_messaging.MessagingTimeout, None]) + ctxt_mock.call = call_mock + self.client_mock.prepare = mock.MagicMock(return_value=ctxt_mock) + self._take_down_agent_and_run_reschedule(VPN_HOSTA) + self.assertEqual(2, call_mock.call_count) + # make sure vpn service was rescheduled even when first attempt + # failed to notify VPN agent + router_id = service['vpnservice']['router_id'] + host = self._get_agent_host_by_router(router_id) + + vpn_agents = self._list_vpn_agents_hosting_router(router_id) + self.assertEqual(1, len(vpn_agents['agents'])) + self.assertEqual(VPN_HOSTB, host) + + def test_router_reschedule_failed_notification_all_attempts(self): + self._register_agent_states() + agent_a = self.service_plugin.get_vpn_agent_on_host( + self.adminContext, VPN_HOSTA) + + with self.vpnservice() as vpnservice: + # schedule the vpn routers to agent A + self.service_plugin.auto_schedule_routers( + self.adminContext, agent_a) + # mock client.prepare and context.call + ctxt_mock = mock.MagicMock() + call_mock = mock.MagicMock( + side_effect=oslo_messaging.MessagingTimeout) + ctxt_mock.call = call_mock + self.client_mock.prepare = mock.MagicMock(return_value=ctxt_mock) + # perform operations + self._take_down_agent_and_run_reschedule(VPN_HOSTA) + self.assertEqual( + vpn_rpc_agent_api.AGENT_NOTIFY_MAX_ATTEMPTS, + call_mock.call_count) + router_id = vpnservice['vpnservice']['router_id'] + vpn_agents = self._list_vpn_agents_hosting_router(router_id) + self.assertEqual(0, len(vpn_agents['agents'])) + + def test_router_auto_schedule_with_hosted(self): + self._register_agent_states() + agent_a = self.service_plugin.get_vpn_agent_on_host( + self.adminContext, VPN_HOSTA) + agent_b = self.service_plugin.get_vpn_agent_on_host( + self.adminContext, VPN_HOSTB) + + with self.vpnservice() as vpnservice: + self._register_agent_states() + ret_a = self.service_plugin.auto_schedule_routers( + self.adminContext, agent_a) + ret_b = self.service_plugin.auto_schedule_routers( + self.adminContext, agent_b) + router_id = vpnservice['vpnservice']['router_id'] + vpn_agents = self._list_vpn_agents_hosting_router(router_id) + host = self._get_agent_host_by_router(router_id) + self.assertTrue(len(ret_a)) + self.assertIn(router_id, ret_a) + self.assertFalse(len(ret_b)) + self.assertEqual(1, len(vpn_agents['agents'])) + self.assertEqual(VPN_HOSTA, host) + + def test_add_router_to_vpn_agent(self): + self._register_agent_states() + agent_a = self.service_plugin.get_vpn_agent_on_host( + self.adminContext, VPN_HOSTA) + agent_a_id = agent_a['id'] + agent_b = self.service_plugin.get_vpn_agent_on_host( + self.adminContext, VPN_HOSTB) + agent_b_id = agent_b['id'] + + with self.router() as router: + router_id = router['router']['id'] + num_before_add = len( + self._list_routers_hosted_by_vpn_agent( + agent_a_id)['routers']) + self._add_router_to_vpn_agent(agent_a_id, router_id) + # add router again to same agent is fine + self._add_router_to_vpn_agent(agent_a_id, router_id) + # add router to a second agent is a conflict + self._add_router_to_vpn_agent(agent_b_id, router_id, + expected_code=exc.HTTPConflict.code) + num_after_add = len( + self._list_routers_hosted_by_vpn_agent( + agent_a_id)['routers']) + self.assertEqual(0, num_before_add) + self.assertEqual(1, num_after_add) + + def test_add_router_to_vpn_agent_wrong_type(self): + self._register_agent_states() + agent_id = self._get_any_metadata_agent_id() + + with self.router() as router: + router_id = router['router']['id'] + # add_router_to_vpn_agent with a metadata agent id shall fail + self._add_router_to_vpn_agent( + agent_id, router_id, + expected_code=exc.HTTPNotFound.code) + + def _test_add_router_to_vpn_agent_db_error(self, exception): + self._register_agent_states() + agent_id = self._vpn_agents_by_host[VPN_HOSTA]['id'] + + with self.router() as router, \ + mock.patch.object(orm.Session, 'add', side_effect=exception): + router_id = router['router']['id'] + + self._add_router_to_vpn_agent( + agent_id, router_id, + expected_code=exc.HTTPConflict.code) + + def test_add_router_to_vpn_agent_duplicate(self): + self._test_add_router_to_vpn_agent_db_error(db_exc.DBDuplicateEntry) + + def test_add_router_to_vpn_agent_reference_error(self): + self._test_add_router_to_vpn_agent_db_error( + db_exc.DBReferenceError('', '', '', '')) + + def test_add_router_to_vpn_agent_db_error(self): + self._test_add_router_to_vpn_agent_db_error(db_exc.DBError) + + def test_list_routers_hosted_by_vpn_agent_with_invalid_agent(self): + invalid_agentid = 'non_existing_agent' + self._list_routers_hosted_by_vpn_agent(invalid_agentid, + exc.HTTPNotFound.code) + + def test_remove_router_from_vpn_agent(self): + self._register_agent_states() + agent_id = self._vpn_agents_by_host[VPN_HOSTA]['id'] + + with self.router() as router: + router_id = router['router']['id'] + + self._add_router_to_vpn_agent(agent_id, router_id) + routers = self._list_routers_hosted_by_vpn_agent(agent_id) + num_before = len(routers['routers']) + + self._remove_router_from_vpn_agent(agent_id, router_id) + routers = self._list_routers_hosted_by_vpn_agent(agent_id) + num_after = len(routers['routers']) + + self.assertEqual(1, num_before) + self.assertEqual(0, num_after) + + def test_remove_router_from_vpn_agent_wrong_agent(self): + self._register_agent_states() + agent_a_id = self._vpn_agents_by_host[VPN_HOSTA]['id'] + agent_b_id = self._vpn_agents_by_host[VPN_HOSTB]['id'] + + with self.router() as router: + router_id = router['router']['id'] + + self._add_router_to_vpn_agent(agent_a_id, router_id) + routers = self._list_routers_hosted_by_vpn_agent(agent_a_id) + num_before = len(routers['routers']) + + # try to remove router from wrong agent is not an error + self._remove_router_from_vpn_agent(agent_b_id, router_id) + routers = self._list_routers_hosted_by_vpn_agent(agent_a_id) + num_after = len(routers['routers']) + + self.assertEqual(1, num_before) + self.assertEqual(1, num_after) + + def test_remove_router_from_vpn_agent_unknown_agent(self): + self._register_agent_states() + agent_a_id = self._vpn_agents_by_host[VPN_HOSTA]['id'] + + with self.router() as router: + router_id = router['router']['id'] + + self._add_router_to_vpn_agent(agent_a_id, router_id) + routers = self._list_routers_hosted_by_vpn_agent(agent_a_id) + num_before = len(routers['routers']) + + # try to remove router from unknown agent is an error + self._remove_router_from_vpn_agent( + 'unknown-agent', router_id, + expected_code=exc.HTTPNotFound.code) + routers = self._list_routers_hosted_by_vpn_agent(agent_a_id) + num_after = len(routers['routers']) + + self.assertEqual(1, num_before) + self.assertEqual(1, num_after) diff --git a/neutron_vpnaas/tests/unit/db/vpn/test_vpn_db.py b/neutron_vpnaas/tests/unit/db/vpn/test_vpn_db.py index b0ab5dc96..54860ade1 100644 --- a/neutron_vpnaas/tests/unit/db/vpn/test_vpn_db.py +++ b/neutron_vpnaas/tests/unit/db/vpn/test_vpn_db.py @@ -2290,3 +2290,77 @@ class TestVpnDatabase(base.NeutronDbPluginV2TestCase, NeutronResourcesMixin): self.context, private_subnet['id'], router['id']) + + def _setup_ipsec_site_connections_with_ep_groups(self, peer_cidr_lists): + private_subnet, router = self.create_basic_topology() + vpn_service_info = self.prepare_service_info(private_subnet=None, + router=router) + vpn_service = self.plugin.create_vpnservice(self.context, + vpn_service_info) + + ike_policy = self.create_ike_policy() + ipsec_policy = self.create_ipsec_policy() + ipsec_site_connection = self.prepare_connection_info( + vpn_service['id'], + ike_policy['id'], + ipsec_policy['id']) + + local_ep_group = self.create_endpoint_group( + group_type='subnet', endpoints=[private_subnet['id']]) + for peer_cidrs in peer_cidr_lists: + peer_ep_group = self.create_endpoint_group( + group_type='cidr', endpoints=peer_cidrs) + ipsec_site_connection['ipsec_site_connection'].update( + {'local_ep_group_id': local_ep_group['id'], + 'peer_ep_group_id': peer_ep_group['id']}) + self.plugin.create_ipsec_site_connection(self.context, + ipsec_site_connection) + return private_subnet, router + + def _setup_ipsec_site_connections_without_ep_groups(self, peer_cidr_lists): + private_subnet, router = self.create_basic_topology() + vpn_service_info = \ + self.prepare_service_info(private_subnet=private_subnet, + router=router) + vpn_service = self.plugin.create_vpnservice(self.context, + vpn_service_info) + + ike_policy = self.create_ike_policy() + ipsec_policy = self.create_ipsec_policy() + ipsec_site_connection = self.prepare_connection_info( + vpn_service['id'], + ike_policy['id'], + ipsec_policy['id']) + + for peer_cidrs in peer_cidr_lists: + ipsec_site_connection['ipsec_site_connection'].update( + {'peer_cidrs': peer_cidrs}) + self.plugin.create_ipsec_site_connection(self.context, + ipsec_site_connection) + return private_subnet, router + + def _test_get_peer_cidrs_for_router(self, setup_func): + mock.patch.object(self.plugin, '_get_validator').start() + + # create 1st setup with two connections + peer_cidrs = [ + ['20.1.0.0/24', '20.2.0.0/24'], + ['20.3.0.0/24'] + ] + private_subnet, router = setup_func(peer_cidrs) + + # create a 2nd setup for a different router + setup_func([['10.1.0.0/24', '10.2.0.0/24']]) + + returned_cidrs = self.plugin.get_peer_cidrs_for_router(self.context, + router['id']) + expected = ['20.1.0.0/24', '20.2.0.0/24', '20.3.0.0/24'] + self.assertEqual(sorted(expected), sorted(returned_cidrs)) + + def test_get_peer_cidrs_for_router_with_ep_groups(self): + self._test_get_peer_cidrs_for_router( + self._setup_ipsec_site_connections_with_ep_groups) + + def test_get_peer_cidrs_for_router_without_ep_groups(self): + self._test_get_peer_cidrs_for_router( + self._setup_ipsec_site_connections_without_ep_groups) diff --git a/neutron_vpnaas/tests/unit/db/vpn/test_vpn_ext_gw_db.py b/neutron_vpnaas/tests/unit/db/vpn/test_vpn_ext_gw_db.py new file mode 100644 index 000000000..2f4628f1d --- /dev/null +++ b/neutron_vpnaas/tests/unit/db/vpn/test_vpn_ext_gw_db.py @@ -0,0 +1,218 @@ +# Copyright 2023 SysEleven GmbH +# +# 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 neutron.api import extensions +from neutron.tests.unit.api import test_extensions +from neutron.tests.unit.extensions import test_l3 as test_l3_plugin +from neutron_lib.callbacks import events +from neutron_lib.callbacks import exceptions as cb_exc +from neutron_lib.callbacks import registry +from neutron_lib.callbacks import resources +from neutron_lib import constants as lib_constants +from neutron_lib import context +from neutron_lib.plugins import constants as nconstants +from neutron_lib.plugins import directory +from neutron_vpnaas.db.vpn.vpn_ext_gw_db import VPNExtGWPlugin_db +from neutron_vpnaas.services.vpn.common import constants as v_constants +from neutron_vpnaas.tests import base +from neutron_vpnaas.tests.unit.db.vpn import test_vpn_db + + +OVN_VPN_PLUGIN_KLASS = "neutron_vpnaas.services.vpn.ovn_plugin.VPNOVNPlugin" + + +class VPNOVNPluginDbTestCase(test_l3_plugin.L3NatTestCaseMixin, + base.NeutronDbPluginV2TestCase): + def setUp(self, core_plugin=None, vpnaas_plugin=OVN_VPN_PLUGIN_KLASS, + vpnaas_provider=None): + + service_plugins = {'vpnaas_plugin': vpnaas_plugin} + plugin_str = 'neutron.tests.unit.extensions.test_l3.TestL3NatIntPlugin' + super().setUp(plugin_str, service_plugins=service_plugins) + ext_mgr = extensions.PluginAwareExtensionManager.get_instance() + self.ext_api = test_extensions.setup_extensions_middleware(ext_mgr) + self.core_plugin = directory.get_plugin() + self.tenant_id = 'tenant1' + + +class TestVPNExtGw(VPNOVNPluginDbTestCase): + def _pre_port_delete(self, admin_context, port_id): + registry.publish( + resources.PORT, events.BEFORE_DELETE, self, + payload=events.DBEventPayload( + admin_context, + metadata={'port_check': True}, + resource_id=port_id)) + + def _pre_subnet_delete(self, admin_context, subnet_id): + registry.publish(resources.SUBNET, events.BEFORE_DELETE, self, + payload=events.DBEventPayload(admin_context, + resource_id=subnet_id)) + + def _pre_network_delete(self, admin_context, network_id): + registry.publish(resources.NETWORK, events.BEFORE_DELETE, self, + payload=events.DBEventPayload(admin_context, + resource_id=network_id)) + + def _test_prevent_vpn_port_deletion(self, device_owner, gw_key): + plugin = directory.get_plugin(nconstants.VPN) + with self.router() as router, \ + self.port(device_owner=device_owner) as port: + gateway = {'gateway': { + 'router_id': router['router']['id'], + gw_key: port['port']['id'], + 'tenant_id': self.tenant_id + }} + admin_context = context.get_admin_context() + plugin.create_gateway(admin_context, gateway) + self.assertRaises( + cb_exc.CallbackFailure, + self._pre_port_delete, admin_context, port['port']['id']) + + def test_prevent_vpn_port_deletion_gw_port(self): + self._test_prevent_vpn_port_deletion( + v_constants.DEVICE_OWNER_VPN_ROUTER_GW, 'gw_port_id') + + def test_prevent_vpn_port_deletion_transit_port(self): + self._test_prevent_vpn_port_deletion( + v_constants.DEVICE_OWNER_TRANSIT_NETWORK, 'transit_port_id') + + def test_prevent_vpn_port_deletion_other_device_owner(self): + plugin = directory.get_plugin(nconstants.VPN) + device_owner = v_constants.DEVICE_OWNER_TRANSIT_NETWORK + with self.router() as router, \ + self.port(device_owner=device_owner) as transit_port, \ + self.port(device_owner='other-device-owner') as other_port: + gateway = {'gateway': { + 'router_id': router['router']['id'], + 'transit_port_id': transit_port['port']['id'], + 'tenant_id': self.tenant_id + }} + admin_context = context.get_admin_context() + plugin.create_gateway(admin_context, gateway) + # BEFORE_DELETE event for other_port should not raise an exception + self._pre_port_delete(admin_context, other_port['port']['id']) + + def test_prevent_vpn_subnet_deletion(self): + plugin = directory.get_plugin(nconstants.VPN) + with self.router() as router, self.subnet() as subnet: + gateway = {'gateway': { + 'router_id': router['router']['id'], + 'transit_subnet_id': subnet['subnet']['id'], + 'tenant_id': self.tenant_id + }} + admin_context = context.get_admin_context() + plugin.create_gateway(admin_context, gateway) + self.assertRaises( + cb_exc.CallbackFailure, + self._pre_subnet_delete, admin_context, subnet['subnet']['id']) + # should not raise an exception for other subnet id + self._pre_subnet_delete(admin_context, "other-id") + + def test_prevent_vpn_network_deletion(self): + plugin = directory.get_plugin(nconstants.VPN) + with self.router() as router, self.network() as network: + gateway = {'gateway': { + 'router_id': router['router']['id'], + 'transit_network_id': network['network']['id'], + 'tenant_id': self.tenant_id + }} + admin_context = context.get_admin_context() + plugin.create_gateway(admin_context, gateway) + self.assertRaises( + cb_exc.CallbackFailure, + self._pre_network_delete, admin_context, + network['network']['id']) + # should not raise an exception for other network id + self._pre_network_delete(admin_context, "other-id") + + +class TestVPNExtGwDB(base.NeutronDbPluginV2TestCase, + test_vpn_db.NeutronResourcesMixin): + def setUp(self): + plugin_str = 'neutron.tests.unit.extensions.test_l3.TestL3NatIntPlugin' + super().setUp(plugin_str) + + self.core_plugin = directory.get_plugin() + self.l3_plugin = directory.get_plugin(nconstants.L3) + self.tenant_id = 'tenant1' + self.context = context.get_admin_context() + + def _create_gw_port(self, router): + port = {'port': { + 'tenant_id': self.tenant_id, + 'network_id': router['external_gateway_info']['network_id'], + 'fixed_ips': lib_constants.ATTR_NOT_SPECIFIED, + 'mac_address': lib_constants.ATTR_NOT_SPECIFIED, + 'admin_state_up': True, + 'device_id': router['id'], + 'device_owner': v_constants.DEVICE_OWNER_VPN_ROUTER_GW, + 'name': '' + }} + return self.core_plugin.create_port(self.context, port) + + def test_create_gateway(self): + private_subnet, router = self.create_basic_topology() + gateway = {'gateway': { + 'router_id': router['id'], + 'tenant_id': self.tenant_id + }} + gwdb = VPNExtGWPlugin_db() + new_gateway = gwdb.create_gateway(self.context, gateway) + expected = {**gateway['gateway'], + 'status': lib_constants.PENDING_CREATE} + self.assertDictSupersetOf(expected, new_gateway) + + def test_update_gateway_with_external_port(self): + private_subnet, router = self.create_basic_topology() + gwdb = VPNExtGWPlugin_db() + # create gateway + gateway = {'gateway': { + 'router_id': router['id'], + 'tenant_id': self.tenant_id + }} + new_gateway = gwdb.create_gateway(self.context, gateway) + + # create external port and update gateway with the port id + gw_port = self._create_gw_port(router) + gateway_update = {'gateway': { + 'gw_port_id': gw_port['id'] + }} + gwdb.update_gateway(self.context, new_gateway['id'], gateway_update) + + # check that get_vpn_gw_dict_by_router_id includes external_fixed_ips + found_gateway = gwdb.get_vpn_gw_dict_by_router_id(self.context, + router['id']) + self.assertIn('external_fixed_ips', found_gateway) + expected = sorted(gw_port['fixed_ips']) + returned = sorted(found_gateway['external_fixed_ips']) + self.assertEqual(returned, expected) + + def test_delete_gateway(self): + private_subnet, router = self.create_basic_topology() + gwdb = VPNExtGWPlugin_db() + # create gateway + gateway = {'gateway': { + 'router_id': router['id'], + 'tenant_id': self.tenant_id + }} + new_gateway = gwdb.create_gateway(self.context, gateway) + self.assertIsNotNone(new_gateway) + deleted = gwdb.delete_gateway(self.context, new_gateway['id']) + self.assertEqual(deleted, 1) + deleted = gwdb.delete_gateway(self.context, new_gateway['id']) + self.assertEqual(deleted, 0) + found_gateway = gwdb.get_vpn_gw_dict_by_router_id(self.context, + router['id']) + self.assertIsNone(found_gateway) diff --git a/neutron_vpnaas/tests/unit/services/vpn/device_drivers/test_ovn_ipsec.py b/neutron_vpnaas/tests/unit/services/vpn/device_drivers/test_ovn_ipsec.py new file mode 100644 index 000000000..43fa0982d --- /dev/null +++ b/neutron_vpnaas/tests/unit/services/vpn/device_drivers/test_ovn_ipsec.py @@ -0,0 +1,260 @@ +# Copyright 2023 SysEleven GmbH. +# +# 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.agent.linux import ip_lib +from neutron.conf.agent import common as agent_config +from neutron.conf import common as common_config +from oslo_config import cfg +from oslo_utils import uuidutils + +from neutron_vpnaas.services.vpn.device_drivers import ovn_ipsec +from neutron_vpnaas.tests import base +from neutron_vpnaas.tests.unit.services.vpn.device_drivers import test_ipsec + +_uuid = uuidutils.generate_uuid + + +FAKE_PROCESS_ID = "c5b52e50-e678-491e-98dd-34e2676a6f81" +FAKE_NAMESPACE_NAME = "qvpn-c5b52e50-e678-491e-98dd-34e2676a6f81" + +FAKE_GW_PORT_ID = "e95d89fb-1723-4865-876f-a1c8efed4b55" +FAKE_GW_PORT_INTERFACE_NAME = "vge95d89fb-172" +FAKE_GW_PORT_IP_ADDRESS = "20.20.20.20" +FAKE_GW_PORT_MAC_ADDRESS = "11:22:33:44:55:66" +FAKE_GW_PORT_SUBNET_ID = _uuid() +FAKE_GW_PORT_SUBNET_INFO = { + 'id': FAKE_GW_PORT_SUBNET_ID, + 'cidr': '20.20.20.0/24', + 'ip_version': 4 +} +FAKE_GW_PORT = { + 'id': FAKE_GW_PORT_ID, + 'mac_address': FAKE_GW_PORT_MAC_ADDRESS, + 'fixed_ips': [{ + 'ip_address': FAKE_GW_PORT_IP_ADDRESS, + 'subnet_id': FAKE_GW_PORT_SUBNET_ID + }] +} +FAKE_TRANSIT_PORT_ID = "0eb4bdb3-fe2e-4724-bb04-f84b6a5974f8" +FAKE_TRANSIT_PORT_INTERFACE_NAME = "vr0eb4bdb3-fe2" +FAKE_TRANSIT_PORT_MAC_ADDRESS = "22:33:44:55:66:77" +FAKE_TRANSIT_PORT_IP_ADDRESS = "169.254.0.2" +FAKE_TRANSIT_PORT_SUBNET_ID = _uuid() +FAKE_TRANSIT_PORT = { + 'id': FAKE_TRANSIT_PORT_ID, + 'mac_address': FAKE_TRANSIT_PORT_MAC_ADDRESS, + 'fixed_ips': [{ + 'ip_address': FAKE_TRANSIT_PORT_IP_ADDRESS, + 'subnet_id': FAKE_TRANSIT_PORT_SUBNET_ID + }] +} + + +def fake_interface_driver(*args, **kwargs): + driver = mock.Mock() + driver.DEV_NAME_LEN = 14 + return driver + + +class TestDeviceManager(base.BaseTestCase): + def setUp(self): + super().setUp() + self.conf = cfg.CONF + self.conf.register_opts(common_config.core_opts) + self.conf.register_opts(agent_config.INTERFACE_DRIVER_OPTS) + self.conf.set_override('interface_driver', + 'neutron_vpnaas.tests.unit.services.vpn.device_drivers' + '.test_ovn_ipsec.fake_interface_driver') + self.host = "some-hostname" + self.plugin = mock.Mock() + self.plugin.get_subnet_info.return_value = FAKE_GW_PORT_SUBNET_INFO + self.context = mock.Mock() + + def test_names(self): + mgr = ovn_ipsec.DeviceManager(self.conf, self.host, + self.plugin, self.context) + port = {'id': "0df5beb8-4794-4217-acde-e6ce4875a59f"} + name = mgr.get_interface_name(port, "internal") + self.assertEqual(name, "vr0df5beb8-479") + + name = mgr.get_interface_name(port, "external") + self.assertEqual(name, "vg0df5beb8-479") + + name = mgr.get_namespace_name("0df5beb8-4794-4217-acde-e6ce4875a59f") + self.assertEqual(name, "qvpn-0df5beb8-4794-4217-acde-e6ce4875a59f") + + def test_setup_external(self): + ext_net_id = _uuid() + network_details = { + 'gw_port': FAKE_GW_PORT, + 'external_network': { + 'id': ext_net_id + } + } + + mgr = ovn_ipsec.DeviceManager(self.conf, self.host, + self.plugin, self.context) + + with mock.patch.object(ip_lib, 'ensure_device_is_ready') as dev_ready: + with mock.patch.object(mgr, 'set_default_route') as set_def_route: + dev_ready.return_value = False + + mgr.setup_external(FAKE_PROCESS_ID, network_details) + + dev_ready.assert_called_once() + self.plugin.get_subnet_info.assert_called_once_with( + FAKE_GW_PORT_SUBNET_ID + ) + set_def_route.assert_called_once_with( + FAKE_NAMESPACE_NAME, + FAKE_GW_PORT_SUBNET_INFO, + FAKE_GW_PORT_INTERFACE_NAME + ) + mgr.driver.init_l3.assert_called_once() + mgr.driver.plug.assert_called_once() + + def test_setup_internal(self): + network_details = {'transit_port': FAKE_TRANSIT_PORT} + + mgr = ovn_ipsec.DeviceManager(self.conf, self.host, + self.plugin, self.context) + + with mock.patch.object(ip_lib, 'ensure_device_is_ready') as dev_ready: + dev_ready.return_value = False + + mgr.setup_internal(FAKE_PROCESS_ID, network_details) + + dev_ready.assert_called_once() + mgr.driver.init_l3.assert_called_once() + mgr.driver.plug.assert_called_once() + + def test_list_routes(self): + mgr = ovn_ipsec.DeviceManager(self.conf, self.host, + self.plugin, self.context) + mock_ipdev = mock.Mock() + routes = [ + {'cidr': '192.168.111.0/24', 'via': FAKE_TRANSIT_PORT_IP_ADDRESS} + ] + with mock.patch.object(ip_lib, 'IPDevice') as ipdev: + ipdev.return_value = mock_ipdev + mock_ipdev.route.list_routes.return_value = routes + returned = mgr.list_routes(FAKE_NAMESPACE_NAME) + self.assertEqual(returned, routes) + + def test_del_static_routes(self): + mgr = ovn_ipsec.DeviceManager(self.conf, self.host, self.plugin, + self.context) + mock_ipdev = mock.Mock() + routes = [ + {'cidr': '192.168.111.0/24', 'via': FAKE_TRANSIT_PORT_IP_ADDRESS}, + {'cidr': '192.168.112.0/24', 'via': FAKE_TRANSIT_PORT_IP_ADDRESS} + ] + with mock.patch.object(ip_lib, 'IPDevice') as ipdev: + ipdev.return_value = mock_ipdev + mock_ipdev.route.list_routes.return_value = routes + + mgr.del_static_routes(FAKE_NAMESPACE_NAME) + + mock_ipdev.route.delete_route.assert_has_calls([ + mock.call(routes[0]['cidr'], via=FAKE_TRANSIT_PORT_IP_ADDRESS), + mock.call(routes[1]['cidr'], via=FAKE_TRANSIT_PORT_IP_ADDRESS), + ], any_order=True) + + +class TestOvnStrongSwanDriver(test_ipsec.IPSecDeviceLegacy): + + def setUp(self, driver=ovn_ipsec.OvnStrongSwanDriver, + ipsec_process=ovn_ipsec.OvnStrongSwanProcess): + conf = cfg.CONF + conf.register_opts(common_config.core_opts) + conf.register_opts(agent_config.INTERFACE_DRIVER_OPTS) + conf.set_override('interface_driver', + 'neutron_vpnaas.tests.unit.services.vpn.device_drivers' + '.test_ovn_ipsec.fake_interface_driver') + + super().setUp(driver, ipsec_process) + self.driver.nsmgr = mock.Mock() + self.driver.nsmgr.exists.return_value = False + self.driver.devmgr = mock.Mock() + self.driver.devmgr.get_namespace_name.return_value = \ + FAKE_NAMESPACE_NAME + self.driver.devmgr.list_routes.return_value = [] + self.driver.devmgr.get_existing_process_ids.return_value = [] + self.driver.agent_rpc.get_vpn_transit_network_details.return_value = { + 'transit_gateway_ip': '192.168.1.1', + } + + def test_iptables_apply(self): + """Not applicable for OvnIPsecDriver""" + pass + + def test_get_namespace_for_router(self): + """Different for OvnIPsecDriver""" + namespace = self.driver.get_namespace(FAKE_PROCESS_ID) + self.assertEqual(FAKE_NAMESPACE_NAME, namespace) + + def test_fail_getting_namespace_for_unknown_router(self): + """Not applicable for OvnIPsecDriver""" + pass + + def test_create_router(self): + """Not applicable for OvnIPsecDriver""" + pass + + def test_destroy_router(self): + """Not applicable for OvnIPsecDriver""" + pass + + def test_remove_rule(self): + """Not applicable for OvnIPsecDriver""" + pass + + def test_add_nat_rules_with_multiple_local_subnets(self): + """Not applicable for OvnIPsecDriver""" + pass + + def _test_add_nat_rule(self): + """Not applicable for OvnIPsecDriver""" + pass + + def test_add_nat_rule(self): + """Not applicable for OvnIPsecDriver""" + pass + + def test_stale_cleanup(self): + process = self.fake_ensure_process(FAKE_PROCESS_ID) + + self.driver.devmgr.get_existing_process_ids.return_value = [ + FAKE_PROCESS_ID] + + self.driver.agent_rpc.get_vpn_services_on_host.return_value = [] + context = mock.Mock() + with mock.patch.object(self.driver, 'ensure_process') as ensure: + ensure.return_value = process + self.driver.sync(context, []) + process.disable.assert_called() + + +class TestOvnOpenSwanDriver(TestOvnStrongSwanDriver): + def setUp(self): + super().setUp(driver=ovn_ipsec.OvnOpenSwanDriver, + ipsec_process=ovn_ipsec.OvnOpenSwanProcess) + + +class TestOvnLibreSwanDriver(TestOvnStrongSwanDriver): + def setUp(self): + super().setUp(driver=ovn_ipsec.OvnLibreSwanDriver, + ipsec_process=ovn_ipsec.OvnLibreSwanProcess) diff --git a/neutron_vpnaas/tests/unit/services/vpn/service_drivers/test_ovn_ipsec.py b/neutron_vpnaas/tests/unit/services/vpn/service_drivers/test_ovn_ipsec.py new file mode 100644 index 000000000..c698a2b3a --- /dev/null +++ b/neutron_vpnaas/tests/unit/services/vpn/service_drivers/test_ovn_ipsec.py @@ -0,0 +1,306 @@ +# Copyright 2020, SysEleven GbmH +# 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_lib import context as n_ctx +from neutron_lib.plugins import constants +from neutron_lib.plugins import directory +from oslo_utils import uuidutils + +from neutron_vpnaas.services.vpn.service_drivers import ipsec_validator +from neutron_vpnaas.services.vpn.service_drivers \ + import ovn_ipsec as ipsec_driver +from neutron_vpnaas.tests import base + + +_uuid = uuidutils.generate_uuid + +FAKE_HOST = 'fake_host' +FAKE_TENANT_ID = 'tenant1' +FAKE_ROUTER_ID = _uuid() +FAKE_TRANSIT_IP_ADDRESS = '169.254.0.2' + +FAKE_VPNSERVICE_1 = { + 'id': _uuid(), + 'router_id': FAKE_ROUTER_ID, + 'tenant_id': FAKE_TENANT_ID +} + +FAKE_VPNSERVICE_2 = { + 'id': _uuid(), + 'router_id': FAKE_ROUTER_ID, + 'tenant_id': FAKE_TENANT_ID +} + +FAKE_VPN_CONNECTION_1 = { + 'vpnservice_id': FAKE_VPNSERVICE_1['id'] +} + + +class FakeSqlQueryObject(dict): + """To fake SqlAlchemy query object and access keys as attributes.""" + + def __init__(self, **entries): + self.__dict__.update(entries) + super(FakeSqlQueryObject, self).__init__(**entries) + + +class FakeGatewayDB(object): + def __init__(self): + self.gateways_by_router = {} + self.gateways_by_id = {} + + def create_gateway(self, context, gateway): + info = gateway['gateway'] + fake_gw = { + 'id': _uuid(), + 'status': 'PENDING_CREATE', + 'external_fixed_ips': [{'subnet_id': '1', + 'ip_address': '10.2.3.4'}], + **info + } + self.gateways_by_router[info['router_id']] = fake_gw + self.gateways_by_id[fake_gw['id']] = fake_gw + return fake_gw + + def update_gateway(self, context, gateway_id, gateway): + self.gateways_by_id[gateway_id].update(**gateway['gateway']) + + def delete_gateway(self, context, gateway_id): + fake_gw = self.gateways_by_id.pop(gateway_id, None) + if fake_gw: + self.gateways_by_router.pop(fake_gw['router_id']) + return 1 if fake_gw else 0 + + def get_vpn_gw_dict_by_router_id(self, context, router_id, refresh=False): + return self.gateways_by_router.get(router_id) + + +class TestOvnIPsecDriver(base.BaseTestCase): + def setUp(self): + super().setUp() + mock.patch('neutron_lib.rpc.Connection').start() + self.create_port = \ + mock.patch('neutron_lib.plugins.utils.create_port').start() + self.create_network = \ + mock.patch('neutron_lib.plugins.utils.create_network').start() + self.create_subnet = \ + mock.patch('neutron_lib.plugins.utils.create_subnet').start() + + self.create_port.side_effect = lambda pl, c, p: { + 'id': _uuid(), + 'fixed_ips': [{'subnet_id': '1', 'ip_address': '10.1.1.2'}]} + self.create_network.side_effect = lambda pl, c, n: {'id': _uuid()} + self.create_subnet.side_effect = lambda pl, c, s: {'id': _uuid()} + + vpn_agent = {'host': FAKE_HOST} + + self.core_plugin = mock.Mock() + self.core_plugin.get_vpn_agents_hosting_routers.return_value = \ + [vpn_agent] + + directory.add_plugin(constants.CORE, self.core_plugin) + + self._fake_router = FakeSqlQueryObject( + id=FAKE_ROUTER_ID, + gw_port=FakeSqlQueryObject(network_id=_uuid()) + ) + + self.l3_plugin = mock.Mock() + self.l3_plugin.get_router.return_value = self._fake_router + directory.add_plugin(constants.L3, self.l3_plugin) + + self.svc_plugin = mock.Mock() + self.svc_plugin.get_vpn_agents_hosting_routers.return_value = \ + [vpn_agent] + self.svc_plugin.schedule_router.return_value = vpn_agent + self.svc_plugin._get_vpnservice.return_value = FakeSqlQueryObject( + router_id=FAKE_ROUTER_ID, + router=self._fake_router + ) + self.svc_plugin.get_vpnservice.return_value = FAKE_VPNSERVICE_1 + self.svc_plugin.get_vpnservice_router_id.return_value = FAKE_ROUTER_ID + self.driver = ipsec_driver.IPsecOvnVPNDriver(self.svc_plugin) + self.validator = ipsec_validator.IpsecVpnValidator(self.driver) + self.context = n_ctx.get_admin_context() + + def test_create_vpnservice(self): + mock.patch.object(self.driver.agent_rpc.client, 'cast') + mock.patch.object(self.driver.agent_rpc.client, 'prepare') + fake_gw_db = FakeGatewayDB() + + self.svc_plugin.get_vpn_gw_dict_by_router_id.side_effect = \ + fake_gw_db.get_vpn_gw_dict_by_router_id + self.svc_plugin.create_gateway.side_effect = fake_gw_db.create_gateway + self.svc_plugin.update_gateway.side_effect = fake_gw_db.update_gateway + + self.driver.create_vpnservice(self.context, FAKE_VPNSERVICE_1) + self.svc_plugin.create_gateway.assert_called_once() + + # check that the plugin utils create functions were called + self.create_port.assert_called() + self.create_network.assert_called_once() + self.create_subnet.assert_called_once() + + # check that the core plugin create functions were not called directly + self.core_plugin.create_port.assert_not_called() + self.core_plugin.create_network.assert_not_called() + self.core_plugin.create_subnet.assert_not_called() + + self.svc_plugin.reset_mock() + + self.driver.create_vpnservice(self.context, FAKE_VPNSERVICE_2) + self.svc_plugin.create_gateway.assert_not_called() + + def test_delete_vpnservice(self): + mock.patch.object(self.driver.agent_rpc.client, 'cast') + mock.patch.object(self.driver.agent_rpc.client, 'prepare') + fake_gw_db = FakeGatewayDB() + self.svc_plugin.get_vpn_gw_dict_by_router_id.side_effect = \ + fake_gw_db.get_vpn_gw_dict_by_router_id + self.svc_plugin.create_gateway.side_effect = fake_gw_db.create_gateway + self.svc_plugin.update_gateway.side_effect = fake_gw_db.update_gateway + self.svc_plugin.delete_gateway.side_effect = fake_gw_db.delete_gateway + + # create 2 VPN services on same router + self.driver.create_vpnservice(self.context, FAKE_VPNSERVICE_1) + self.driver.create_vpnservice(self.context, FAKE_VPNSERVICE_2) + self.svc_plugin.reset_mock() + + # deleting one VPN service must not delete the VPN gateway + self.svc_plugin.get_vpnservices.return_value = [FAKE_VPNSERVICE_2] + self.driver.delete_vpnservice(self.context, FAKE_VPNSERVICE_1) + self.core_plugin.delete_port.assert_not_called() + self.core_plugin.delete_network.assert_not_called() + self.core_plugin.delete_subnet.assert_not_called() + self.svc_plugin.create_gateway.assert_not_called() + self.svc_plugin.delete_gateway.assert_not_called() + + # deleting last VPN service shall delete the VPN gateway + self.svc_plugin.get_vpnservices.return_value = [] + self.driver.delete_vpnservice(self.context, FAKE_VPNSERVICE_1) + self.core_plugin.delete_port.assert_called() + self.core_plugin.delete_network.assert_called_once() + self.core_plugin.delete_subnet.assert_called_once() + self.svc_plugin.create_gateway.assert_not_called() + self.svc_plugin.delete_gateway.assert_called_once() + + def _test_ipsec_site_connection(self, old_peers, new_peers, + func, args, + expected_add, expected_remove): + self._fake_router['routes'] = [ + {'destination': peer, 'nexthop': FAKE_TRANSIT_IP_ADDRESS} + for peer in old_peers + ] + transit_port = FakeSqlQueryObject( + id=_uuid(), + fixed_ips=[ + {'subnet_id': _uuid(), 'ip_address': FAKE_TRANSIT_IP_ADDRESS} + ] + ) + self.svc_plugin.get_vpn_gw_by_router_id.return_value = \ + FakeSqlQueryObject(id=_uuid(), + router_id=FAKE_ROUTER_ID, + transit_port_id=transit_port.id, + transit_port=transit_port) + + self.svc_plugin.get_peer_cidrs_for_router.return_value = new_peers + + # create/update/delete_ipsec_site_connection + with mock.patch.object(self.driver.agent_rpc.client, 'cast' + ) as rpc_mock, \ + mock.patch.object(self.driver.agent_rpc.client, 'prepare' + ) as prepare_mock: + prepare_mock.return_value = self.driver.agent_rpc.client + func(self.context, *args) + + prepare_args = {'server': 'fake_host', 'version': '1.0'} + prepare_mock.assert_called_once_with(**prepare_args) + + # check that agent RPC vpnservice_updated is called + rpc_mock.assert_called_once_with(self.context, 'vpnservice_updated', + router={'id': FAKE_ROUTER_ID}) + + # check that routes were updated + if expected_add: + expected_router = {'router': {'routes': [ + {'destination': peer, + 'nexthop': FAKE_TRANSIT_IP_ADDRESS} + for peer in expected_add + ]}} + self.l3_plugin.add_extraroutes.assert_called_once_with( + self.context, FAKE_ROUTER_ID, expected_router) + else: + self.l3_plugin.add_extraroutes.assert_not_called() + + if expected_remove: + expected_router = {'router': {'routes': [ + {'destination': peer, + 'nexthop': FAKE_TRANSIT_IP_ADDRESS} + for peer in expected_remove + ]}} + self.l3_plugin.remove_extraroutes.assert_called_once_with( + self.context, FAKE_ROUTER_ID, expected_router) + else: + self.l3_plugin.remove_extraroutes.assert_not_called() + + def test_create_ipsec_site_connection_1(self): + old_peers = [] + new_peers = ['192.168.1.0/24'] + expected_add = new_peers + expected_remove = [] + self._test_ipsec_site_connection( + old_peers, new_peers, + self.driver.create_ipsec_site_connection, + [FAKE_VPN_CONNECTION_1], + expected_add, expected_remove + ) + + def test_create_ipsec_site_connection_2(self): + """Test creating a 2nd site connection.""" + old_peers = ['192.168.1.0/24'] + new_peers = ['192.168.1.0/24', '192.168.2.0/24'] + expected_add = ['192.168.2.0/24'] + expected_remove = [] + self._test_ipsec_site_connection( + old_peers, new_peers, + self.driver.create_ipsec_site_connection, + [FAKE_VPN_CONNECTION_1], + expected_add, expected_remove + ) + + def test_update_ipsec_site_connection(self): + old_peers = ['192.168.1.0/24'] + new_peers = ['192.168.2.0/24'] + expected_add = new_peers + expected_remove = old_peers + self._test_ipsec_site_connection( + old_peers, new_peers, + self.driver.update_ipsec_site_connection, + [FAKE_VPN_CONNECTION_1, FAKE_VPN_CONNECTION_1], + expected_add, expected_remove + ) + + def test_delete_ipsec_site_connection(self): + old_peers = ['192.168.1.0/24', '192.168.2.0/24'] + new_peers = ['192.168.2.0/24'] + expected_add = [] + expected_remove = ['192.168.1.0/24'] + self._test_ipsec_site_connection( + old_peers, new_peers, + self.driver.delete_ipsec_site_connection, + [FAKE_VPN_CONNECTION_1], + expected_add, expected_remove + ) diff --git a/releasenotes/notes/vpnaas-for-ovn-a487c62b877e3201.yaml b/releasenotes/notes/vpnaas-for-ovn-a487c62b877e3201.yaml new file mode 100644 index 000000000..0b9716c7c --- /dev/null +++ b/releasenotes/notes/vpnaas-for-ovn-a487c62b877e3201.yaml @@ -0,0 +1,8 @@ +--- +prelude: > + VPNaaS support for ML2/OVN +features: + - | + Neutron VPNaaS now supports OVN networking. There is a new stand-alone + VPN agent to support ML2/OVN+VPN. OVN-specific service and device drivers + have been added. diff --git a/setup.cfg b/setup.cfg index 0eb7714ee..be1708273 100644 --- a/setup.cfg +++ b/setup.cfg @@ -30,6 +30,7 @@ data_files = [entry_points] console_scripts = neutron-vpn-netns-wrapper = neutron_vpnaas.services.vpn.common.netns_wrapper:main + neutron-ovn-vpn-agent = neutron_vpnaas.cmd.eventlet.ovn_agent:main neutron.agent.l3.extensions = vpnaas = neutron_vpnaas.services.vpn.agent:L3WithVPNaaS device_drivers = @@ -38,10 +39,12 @@ neutron.db.alembic_migrations = neutron-vpnaas = neutron_vpnaas.db.migration:alembic_migrations neutron.service_plugins = vpnaas = neutron_vpnaas.services.vpn.plugin:VPNDriverPlugin + ovn-vpnaas = neutron_vpnaas.services.vpn.ovn_plugin:VPNOVNDriverPlugin neutron.services.vpn.plugin.VPNDriverPlugin = neutron_vpnaas.services.vpn.plugin:VPNDriverPlugin oslo.config.opts = neutron.vpnaas = neutron_vpnaas.opts:list_opts neutron.vpnaas.agent = neutron_vpnaas.opts:list_agent_opts + neutron.vpnaas.ovn_agent = neutron_vpnaas.opts:list_ovn_agent_opts oslo.policy.policies = neutron-vpnaas = neutron_vpnaas.policies:list_rules neutron.policies =