VPNaaS Service Driver for Cisco CSR
This has the service driver part of the vendor specific VPNaaS plugin. This version DOES NOT rely on the Service Type Framework code, which is presently under review (client 53602, server 41827) and on hold due to discussion over flavors. As a result, this changeset has modifications so that the service driver is not hard-coded in the VPN plugin. The device driver will be under a separate review and has the REST client that talks to the Cisco CSR (running out-of-band). Note: See review 74156 for more details on device driver portion of this blueprint. Change-Id: I39b1475c992b594256f5a28be0caa1ee9398050e Partially-implements: blueprint vpnaas-cisco-driver
This commit is contained in:
parent
bc8a2dcf76
commit
fd8e37e221
@ -418,9 +418,12 @@ signing_dir = $state_path/keystone-signing
|
|||||||
# service_provider=FIREWALL:name2:firewall_driver_path
|
# service_provider=FIREWALL:name2:firewall_driver_path
|
||||||
# --- Reference implementations ---
|
# --- Reference implementations ---
|
||||||
service_provider=LOADBALANCER:Haproxy:neutron.services.loadbalancer.drivers.haproxy.plugin_driver.HaproxyOnHostPluginDriver:default
|
service_provider=LOADBALANCER:Haproxy:neutron.services.loadbalancer.drivers.haproxy.plugin_driver.HaproxyOnHostPluginDriver:default
|
||||||
|
service_provider=VPN:openswan:neutron.services.vpn.service_drivers.ipsec.IPsecVPNDriver:default
|
||||||
# In order to activate Radware's lbaas driver you need to uncomment the next line.
|
# In order to activate Radware's lbaas driver you need to uncomment the next line.
|
||||||
# If you want to keep the HA Proxy as the default lbaas driver, remove the attribute default from the line below.
|
# If you want to keep the HA Proxy as the default lbaas driver, remove the attribute default from the line below.
|
||||||
# Otherwise comment the HA Proxy line
|
# Otherwise comment the HA Proxy line
|
||||||
# service_provider = LOADBALANCER:Radware:neutron.services.loadbalancer.drivers.radware.driver.LoadBalancerDriver:default
|
# service_provider = LOADBALANCER:Radware:neutron.services.loadbalancer.drivers.radware.driver.LoadBalancerDriver:default
|
||||||
# uncomment the following line to make the 'netscaler' LBaaS provider available.
|
# uncomment the following line to make the 'netscaler' LBaaS provider available.
|
||||||
# service_provider=LOADBALANCER:NetScaler:neutron.services.loadbalancer.drivers.netscaler.netscaler_driver.NetScalerPluginDriver
|
# service_provider=LOADBALANCER:NetScaler:neutron.services.loadbalancer.drivers.netscaler.netscaler_driver.NetScalerPluginDriver
|
||||||
|
# Uncomment the following line (and comment out the OpenSwan VPN line) to enable Cisco's VPN driver.
|
||||||
|
# service_provider=VPN:cisco:neutron.services.vpn.service_drivers.cisco_ipsec.CiscoCsrIPsecVPNDriver:default
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
# vpn device drivers which vpn agent will use
|
# vpn device drivers which vpn agent will use
|
||||||
# If we want to use multiple drivers, we need to define this option multiple times.
|
# If we want to use multiple drivers, we need to define this option multiple times.
|
||||||
# vpn_device_driver=neutron.services.vpn.device_drivers.ipsec.OpenSwanDriver
|
# vpn_device_driver=neutron.services.vpn.device_drivers.ipsec.OpenSwanDriver
|
||||||
|
# vpn_device_driver=neutron.services.vpn.device_drivers.cisco_ipsec.CiscoCsrIPsecDriver
|
||||||
# vpn_device_driver=another_driver
|
# vpn_device_driver=another_driver
|
||||||
|
|
||||||
[ipsec]
|
[ipsec]
|
||||||
|
@ -0,0 +1,60 @@
|
|||||||
|
# Copyright 2014 Cisco Systems, Inc. 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.
|
||||||
|
|
||||||
|
"""Cisco CSR VPNaaS
|
||||||
|
|
||||||
|
Revision ID: 24c7ea5160d7
|
||||||
|
Revises: 492a106273f8
|
||||||
|
Create Date: 2014-02-03 13:06:50.407601
|
||||||
|
"""
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '24c7ea5160d7'
|
||||||
|
down_revision = '492a106273f8'
|
||||||
|
|
||||||
|
# Change to ['*'] if this migration applies to all plugins
|
||||||
|
|
||||||
|
migration_for_plugins = [
|
||||||
|
'neutron.services.vpn.plugin.VPNDriverPlugin',
|
||||||
|
]
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from neutron.db import migration
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade(active_plugins=None, options=None):
|
||||||
|
if not migration.should_run(active_plugins, migration_for_plugins):
|
||||||
|
return
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'cisco_csr_identifier_map',
|
||||||
|
sa.Column('tenant_id', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('ipsec_site_conn_id', sa.String(length=64),
|
||||||
|
primary_key=True),
|
||||||
|
sa.Column('csr_tunnel_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('csr_ike_policy_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('csr_ipsec_policy_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['ipsec_site_conn_id'],
|
||||||
|
['ipsec_site_connections.id'],
|
||||||
|
ondelete='CASCADE')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade(active_plugins=None, options=None):
|
||||||
|
if not migration.should_run(active_plugins, migration_for_plugins):
|
||||||
|
return
|
||||||
|
|
||||||
|
op.drop_table('cisco_csr_identifier_map')
|
@ -367,6 +367,25 @@ class VPNPluginDb(VPNPluginBase, base_db.CommonDbMixin):
|
|||||||
self._make_ipsec_site_connection_dict,
|
self._make_ipsec_site_connection_dict,
|
||||||
filters=filters, fields=fields)
|
filters=filters, fields=fields)
|
||||||
|
|
||||||
|
def update_ipsec_site_conn_status(self, context, conn_id, new_status):
|
||||||
|
with context.session.begin():
|
||||||
|
self._update_connection_status(context, conn_id, new_status, True)
|
||||||
|
|
||||||
|
def _update_connection_status(self, context, conn_id, new_status,
|
||||||
|
updated_pending):
|
||||||
|
"""Update the connection status, if changed.
|
||||||
|
|
||||||
|
If the connection is not in a pending state, unconditionally update
|
||||||
|
the status. Likewise, if in a pending state, and have an indication
|
||||||
|
that the status has changed, then update the database.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
conn_db = self._get_ipsec_site_connection(context, conn_id)
|
||||||
|
except vpnaas.IPsecSiteConnectionNotFound:
|
||||||
|
return
|
||||||
|
if not utils.in_pending_status(conn_db.status) or updated_pending:
|
||||||
|
conn_db.status = new_status
|
||||||
|
|
||||||
def _make_ikepolicy_dict(self, ikepolicy, fields=None):
|
def _make_ikepolicy_dict(self, ikepolicy, fields=None):
|
||||||
res = {'id': ikepolicy['id'],
|
res = {'id': ikepolicy['id'],
|
||||||
'tenant_id': ikepolicy['tenant_id'],
|
'tenant_id': ikepolicy['tenant_id'],
|
||||||
@ -667,11 +686,6 @@ class VPNPluginRpcDbMixin():
|
|||||||
vpnservice_db.status = vpnservice['status']
|
vpnservice_db.status = vpnservice['status']
|
||||||
for conn_id, conn in vpnservice[
|
for conn_id, conn in vpnservice[
|
||||||
'ipsec_site_connections'].items():
|
'ipsec_site_connections'].items():
|
||||||
try:
|
self._update_connection_status(
|
||||||
conn_db = self._get_ipsec_site_connection(
|
context, conn_id, conn['status'],
|
||||||
context, conn_id)
|
conn['updated_pending_status'])
|
||||||
except vpnaas.IPsecSiteConnectionNotFound:
|
|
||||||
continue
|
|
||||||
if (not utils.in_pending_status(conn_db.status)
|
|
||||||
or conn['updated_pending_status']):
|
|
||||||
conn_db.status = conn['status']
|
|
||||||
|
@ -18,3 +18,5 @@
|
|||||||
|
|
||||||
IPSEC_DRIVER_TOPIC = 'ipsec_driver'
|
IPSEC_DRIVER_TOPIC = 'ipsec_driver'
|
||||||
IPSEC_AGENT_TOPIC = 'ipsec_agent'
|
IPSEC_AGENT_TOPIC = 'ipsec_agent'
|
||||||
|
CISCO_IPSEC_DRIVER_TOPIC = 'cisco_csr_ipsec_driver'
|
||||||
|
CISCO_IPSEC_AGENT_TOPIC = 'cisco_csr_ipsec_agent'
|
||||||
|
@ -19,7 +19,11 @@
|
|||||||
# @author: Swaminathan Vasudevan, Hewlett-Packard
|
# @author: Swaminathan Vasudevan, Hewlett-Packard
|
||||||
|
|
||||||
from neutron.db.vpn import vpn_db
|
from neutron.db.vpn import vpn_db
|
||||||
from neutron.services.vpn.service_drivers import ipsec as ipsec_driver
|
from neutron.openstack.common import log as logging
|
||||||
|
from neutron.plugins.common import constants
|
||||||
|
from neutron.services import service_base
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class VPNPlugin(vpn_db.VPNPluginDb):
|
class VPNPlugin(vpn_db.VPNPluginDb):
|
||||||
@ -30,7 +34,7 @@ class VPNPlugin(vpn_db.VPNPluginDb):
|
|||||||
Most DB related works are implemented in class
|
Most DB related works are implemented in class
|
||||||
vpn_db.VPNPluginDb.
|
vpn_db.VPNPluginDb.
|
||||||
"""
|
"""
|
||||||
supported_extension_aliases = ["vpnaas"]
|
supported_extension_aliases = ["vpnaas", "service-type"]
|
||||||
|
|
||||||
|
|
||||||
class VPNDriverPlugin(VPNPlugin, vpn_db.VPNPluginRpcDbMixin):
|
class VPNDriverPlugin(VPNPlugin, vpn_db.VPNPluginRpcDbMixin):
|
||||||
@ -38,7 +42,11 @@ class VPNDriverPlugin(VPNPlugin, vpn_db.VPNPluginRpcDbMixin):
|
|||||||
#TODO(nati) handle ikepolicy and ipsecpolicy update usecase
|
#TODO(nati) handle ikepolicy and ipsecpolicy update usecase
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super(VPNDriverPlugin, self).__init__()
|
super(VPNDriverPlugin, self).__init__()
|
||||||
self.ipsec_driver = ipsec_driver.IPsecVPNDriver(self)
|
# Load the service driver from neutron.conf.
|
||||||
|
drivers, default_provider = service_base.load_drivers(
|
||||||
|
constants.VPN, self)
|
||||||
|
LOG.info(_("VPN plugin using service driver: %s"), default_provider)
|
||||||
|
self.ipsec_driver = drivers[default_provider]
|
||||||
|
|
||||||
def _get_driver_for_vpnservice(self, vpnservice):
|
def _get_driver_for_vpnservice(self, vpnservice):
|
||||||
return self.ipsec_driver
|
return self.ipsec_driver
|
||||||
|
@ -19,10 +19,20 @@ import abc
|
|||||||
|
|
||||||
import six
|
import six
|
||||||
|
|
||||||
|
from neutron import manager
|
||||||
|
from neutron.openstack.common import log as logging
|
||||||
|
from neutron.openstack.common.rpc import proxy
|
||||||
|
from neutron.plugins.common import constants
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@six.add_metaclass(abc.ABCMeta)
|
@six.add_metaclass(abc.ABCMeta)
|
||||||
class VpnDriver(object):
|
class VpnDriver(object):
|
||||||
|
|
||||||
|
def __init__(self, service_plugin):
|
||||||
|
self.service_plugin = service_plugin
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def service_type(self):
|
def service_type(self):
|
||||||
pass
|
pass
|
||||||
@ -39,3 +49,43 @@ class VpnDriver(object):
|
|||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def delete_vpnservice(self, context, vpnservice):
|
def delete_vpnservice(self, context, vpnservice):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BaseIPsecVpnAgentApi(proxy.RpcProxy):
|
||||||
|
"""Base class for IPSec API to agent."""
|
||||||
|
|
||||||
|
def __init__(self, to_agent_topic, topic, default_version):
|
||||||
|
self.to_agent_topic = to_agent_topic
|
||||||
|
super(BaseIPsecVpnAgentApi, self).__init__(topic, default_version)
|
||||||
|
|
||||||
|
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.is_admin and context or context.elevated()
|
||||||
|
plugin = manager.NeutronManager.get_service_plugins().get(
|
||||||
|
constants.L3_ROUTER_NAT)
|
||||||
|
if not version:
|
||||||
|
version = self.RPC_API_VERSION
|
||||||
|
l3_agents = plugin.get_l3_agents_hosting_routers(
|
||||||
|
admin_context, [router_id],
|
||||||
|
admin_state_up=True,
|
||||||
|
active=True)
|
||||||
|
for l3_agent in l3_agents:
|
||||||
|
LOG.debug(_('Notify agent at %(topic)s.%(host)s the message '
|
||||||
|
'%(method)s'),
|
||||||
|
{'topic': self.to_agent_topic,
|
||||||
|
'host': l3_agent.host,
|
||||||
|
'method': method,
|
||||||
|
'args': kwargs})
|
||||||
|
self.cast(
|
||||||
|
context, self.make_msg(method, **kwargs),
|
||||||
|
version=version,
|
||||||
|
topic='%s.%s' % (self.to_agent_topic, l3_agent.host))
|
||||||
|
|
||||||
|
def vpnservice_updated(self, context, router_id):
|
||||||
|
"""Send update event of vpnservices."""
|
||||||
|
self._agent_notification(context, 'vpnservice_updated', router_id)
|
||||||
|
235
neutron/services/vpn/service_drivers/cisco_csr_db.py
Normal file
235
neutron/services/vpn/service_drivers/cisco_csr_db.py
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
# Copyright 2014 Cisco Systems, Inc. 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 sqlalchemy as sa
|
||||||
|
from sqlalchemy.orm import exc as sql_exc
|
||||||
|
|
||||||
|
from neutron.common import exceptions
|
||||||
|
from neutron.db import model_base
|
||||||
|
from neutron.db import models_v2
|
||||||
|
from neutron.db.vpn import vpn_db
|
||||||
|
from neutron.openstack.common.db import exception as db_exc
|
||||||
|
from neutron.openstack.common import log as logging
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Note: Artificially limit these to reduce mapping table size and performance
|
||||||
|
# Tunnel can be 0..7FFFFFFF, IKE policy can be 1..10000, IPSec policy can be
|
||||||
|
# 1..31 characters long.
|
||||||
|
MAX_CSR_TUNNELS = 10000
|
||||||
|
MAX_CSR_IKE_POLICIES = 2000
|
||||||
|
MAX_CSR_IPSEC_POLICIES = 2000
|
||||||
|
|
||||||
|
TUNNEL = 'Tunnel'
|
||||||
|
IKE_POLICY = 'IKE Policy'
|
||||||
|
IPSEC_POLICY = 'IPSec Policy'
|
||||||
|
|
||||||
|
MAPPING_LIMITS = {TUNNEL: (0, MAX_CSR_TUNNELS),
|
||||||
|
IKE_POLICY: (1, MAX_CSR_IKE_POLICIES),
|
||||||
|
IPSEC_POLICY: (1, MAX_CSR_IPSEC_POLICIES)}
|
||||||
|
|
||||||
|
|
||||||
|
class CsrInternalError(exceptions.NeutronException):
|
||||||
|
message = _("Fatal - %(reason)s")
|
||||||
|
|
||||||
|
|
||||||
|
class IdentifierMap(model_base.BASEV2, models_v2.HasTenant):
|
||||||
|
|
||||||
|
"""Maps OpenStack IDs to compatible numbers for Cisco CSR."""
|
||||||
|
|
||||||
|
__tablename__ = 'cisco_csr_identifier_map'
|
||||||
|
|
||||||
|
ipsec_site_conn_id = sa.Column(sa.String(64),
|
||||||
|
sa.ForeignKey('ipsec_site_connections.id',
|
||||||
|
ondelete="CASCADE"),
|
||||||
|
primary_key=True)
|
||||||
|
csr_tunnel_id = sa.Column(sa.Integer, nullable=False)
|
||||||
|
csr_ike_policy_id = sa.Column(sa.Integer, nullable=False)
|
||||||
|
csr_ipsec_policy_id = sa.Column(sa.Integer, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
def get_next_available_id(session, table_field, id_type):
|
||||||
|
"""Find first unused id for the specified field in IdentifierMap table.
|
||||||
|
|
||||||
|
As entries are removed, find the first "hole" and return that as the
|
||||||
|
next available ID. To improve performance, artificially limit
|
||||||
|
the number of entries to a smaller range. Currently, these IDs are
|
||||||
|
globally unique. Could enhance in the future to be unique per router
|
||||||
|
(CSR).
|
||||||
|
"""
|
||||||
|
min_value = MAPPING_LIMITS[id_type][0]
|
||||||
|
max_value = MAPPING_LIMITS[id_type][1]
|
||||||
|
rows = session.query(table_field).order_by(table_field)
|
||||||
|
used_ids = set([row[0] for row in rows])
|
||||||
|
all_ids = set(range(min_value, max_value + min_value))
|
||||||
|
available_ids = all_ids - used_ids
|
||||||
|
if not available_ids:
|
||||||
|
msg = _("No available Cisco CSR %(type)s IDs from "
|
||||||
|
"%(min)d..%(max)d") % {'type': id_type,
|
||||||
|
'min': min_value,
|
||||||
|
'max': max_value}
|
||||||
|
LOG.error(msg)
|
||||||
|
raise IndexError(msg)
|
||||||
|
return available_ids.pop()
|
||||||
|
|
||||||
|
|
||||||
|
def get_next_available_tunnel_id(session):
|
||||||
|
"""Find first available tunnel ID from 0..MAX_CSR_TUNNELS-1."""
|
||||||
|
return get_next_available_id(session, IdentifierMap.csr_tunnel_id,
|
||||||
|
TUNNEL)
|
||||||
|
|
||||||
|
|
||||||
|
def get_next_available_ike_policy_id(session):
|
||||||
|
"""Find first available IKE Policy ID from 1..MAX_CSR_IKE_POLICIES."""
|
||||||
|
return get_next_available_id(session, IdentifierMap.csr_ike_policy_id,
|
||||||
|
IKE_POLICY)
|
||||||
|
|
||||||
|
|
||||||
|
def get_next_available_ipsec_policy_id(session):
|
||||||
|
"""Find first available IPSec Policy ID from 1..MAX_CSR_IKE_POLICIES."""
|
||||||
|
return get_next_available_id(session, IdentifierMap.csr_ipsec_policy_id,
|
||||||
|
IPSEC_POLICY)
|
||||||
|
|
||||||
|
|
||||||
|
def find_conn_with_policy(policy_field, policy_id, conn_id, session):
|
||||||
|
"""Return ID of another conneciton (if any) that uses same policy ID."""
|
||||||
|
qry = session.query(vpn_db.IPsecSiteConnection.id)
|
||||||
|
match = qry.filter(policy_field == policy_id,
|
||||||
|
vpn_db.IPsecSiteConnection.id != conn_id).first()
|
||||||
|
if match:
|
||||||
|
return match[0]
|
||||||
|
|
||||||
|
|
||||||
|
def find_connection_using_ike_policy(ike_policy_id, conn_id, session):
|
||||||
|
"""Return ID of another connection that uses same IKE policy ID."""
|
||||||
|
return find_conn_with_policy(vpn_db.IPsecSiteConnection.ikepolicy_id,
|
||||||
|
ike_policy_id, conn_id, session)
|
||||||
|
|
||||||
|
|
||||||
|
def find_connection_using_ipsec_policy(ipsec_policy_id, conn_id, session):
|
||||||
|
"""Return ID of another connection that uses same IPSec policy ID."""
|
||||||
|
return find_conn_with_policy(vpn_db.IPsecSiteConnection.ipsecpolicy_id,
|
||||||
|
ipsec_policy_id, conn_id, session)
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_policy(policy_type, policy_field, conn_id, session):
|
||||||
|
"""Obtain specified policy's mapping from other connection."""
|
||||||
|
try:
|
||||||
|
return session.query(policy_field).filter_by(
|
||||||
|
ipsec_site_conn_id=conn_id).one()[0]
|
||||||
|
except sql_exc.NoResultFound:
|
||||||
|
msg = _("Database inconsistency between IPSec connection and "
|
||||||
|
"Cisco CSR mapping table (%s)") % policy_type
|
||||||
|
raise CsrInternalError(reason=msg)
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_ike_policy_id_for(conn_id, session):
|
||||||
|
"""Obtain existing Cisco CSR IKE policy ID from another connection."""
|
||||||
|
return lookup_policy(IKE_POLICY, IdentifierMap.csr_ike_policy_id,
|
||||||
|
conn_id, session)
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_ipsec_policy_id_for(conn_id, session):
|
||||||
|
"""Obtain existing Cisco CSR IPSec policy ID from another connection."""
|
||||||
|
return lookup_policy(IPSEC_POLICY, IdentifierMap.csr_ipsec_policy_id,
|
||||||
|
conn_id, session)
|
||||||
|
|
||||||
|
|
||||||
|
def determine_csr_policy_id(policy_type, conn_policy_field, map_policy_field,
|
||||||
|
policy_id, conn_id, session):
|
||||||
|
"""Use existing or reserve a new policy ID for Cisco CSR use.
|
||||||
|
|
||||||
|
TODO(pcm) FUTURE: Once device driver adds support for IKE/IPSec policy
|
||||||
|
ID sharing, add call to find_conn_with_policy() to find used ID and
|
||||||
|
then call lookup_policy() to find the current mapping for that ID.
|
||||||
|
"""
|
||||||
|
csr_id = get_next_available_id(session, map_policy_field, policy_type)
|
||||||
|
LOG.debug(_("Reserved new CSR ID %(csr_id)d for %(policy)s "
|
||||||
|
"ID %(policy_id)s"), {'csr_id': csr_id,
|
||||||
|
'policy': policy_type,
|
||||||
|
'policy_id': policy_id})
|
||||||
|
return csr_id
|
||||||
|
|
||||||
|
|
||||||
|
def determine_csr_ike_policy_id(ike_policy_id, conn_id, session):
|
||||||
|
"""Use existing, or reserve a new IKE policy ID for Cisco CSR."""
|
||||||
|
return determine_csr_policy_id(IKE_POLICY,
|
||||||
|
vpn_db.IPsecSiteConnection.ikepolicy_id,
|
||||||
|
IdentifierMap.csr_ike_policy_id,
|
||||||
|
ike_policy_id, conn_id, session)
|
||||||
|
|
||||||
|
|
||||||
|
def determine_csr_ipsec_policy_id(ipsec_policy_id, conn_id, session):
|
||||||
|
"""Use existing, or reserve a new IPSec policy ID for Cisco CSR."""
|
||||||
|
return determine_csr_policy_id(IPSEC_POLICY,
|
||||||
|
vpn_db.IPsecSiteConnection.ipsecpolicy_id,
|
||||||
|
IdentifierMap.csr_ipsec_policy_id,
|
||||||
|
ipsec_policy_id, conn_id, session)
|
||||||
|
|
||||||
|
|
||||||
|
def get_tunnel_mapping_for(conn_id, session):
|
||||||
|
try:
|
||||||
|
entry = session.query(IdentifierMap).filter_by(
|
||||||
|
ipsec_site_conn_id=conn_id).one()
|
||||||
|
LOG.debug(_("Mappings for IPSec connection %(conn)s - "
|
||||||
|
"tunnel=%(tunnel)s ike_policy=%(csr_ike)d "
|
||||||
|
"ipsec_policy=%(csr_ipsec)d"),
|
||||||
|
{'conn': conn_id, 'tunnel': entry.csr_tunnel_id,
|
||||||
|
'csr_ike': entry.csr_ike_policy_id,
|
||||||
|
'csr_ipsec': entry.csr_ipsec_policy_id})
|
||||||
|
return (entry.csr_tunnel_id, entry.csr_ike_policy_id,
|
||||||
|
entry.csr_ipsec_policy_id)
|
||||||
|
except sql_exc.NoResultFound:
|
||||||
|
msg = _("Existing entry for IPSec connection %s not found in Cisco "
|
||||||
|
"CSR mapping table") % conn_id
|
||||||
|
raise CsrInternalError(reason=msg)
|
||||||
|
|
||||||
|
|
||||||
|
def create_tunnel_mapping(context, conn_info):
|
||||||
|
"""Create Cisco CSR IDs, using mapping table and OpenStack UUIDs."""
|
||||||
|
conn_id = conn_info['id']
|
||||||
|
ike_policy_id = conn_info['ikepolicy_id']
|
||||||
|
ipsec_policy_id = conn_info['ipsecpolicy_id']
|
||||||
|
tenant_id = conn_info['tenant_id']
|
||||||
|
with context.session.begin():
|
||||||
|
csr_tunnel_id = get_next_available_tunnel_id(context.session)
|
||||||
|
csr_ike_id = determine_csr_ike_policy_id(ike_policy_id, conn_id,
|
||||||
|
context.session)
|
||||||
|
csr_ipsec_id = determine_csr_ipsec_policy_id(ipsec_policy_id, conn_id,
|
||||||
|
context.session)
|
||||||
|
map_entry = IdentifierMap(tenant_id=tenant_id,
|
||||||
|
ipsec_site_conn_id=conn_id,
|
||||||
|
csr_tunnel_id=csr_tunnel_id,
|
||||||
|
csr_ike_policy_id=csr_ike_id,
|
||||||
|
csr_ipsec_policy_id=csr_ipsec_id)
|
||||||
|
try:
|
||||||
|
context.session.add(map_entry)
|
||||||
|
context.session.flush()
|
||||||
|
except db_exc.DBDuplicateEntry:
|
||||||
|
msg = _("Attempt to create duplicate entry in Cisco CSR "
|
||||||
|
"mapping table for connection %s") % conn_id
|
||||||
|
raise CsrInternalError(reason=msg)
|
||||||
|
LOG.info(_("Mapped connection %(conn_id)s to Tunnel%(tunnel_id)d "
|
||||||
|
"using IKE policy ID %(ike_id)d and IPSec policy "
|
||||||
|
"ID %(ipsec_id)d"),
|
||||||
|
{'conn_id': conn_id, 'tunnel_id': csr_tunnel_id,
|
||||||
|
'ike_id': csr_ike_id, 'ipsec_id': csr_ipsec_id})
|
||||||
|
|
||||||
|
|
||||||
|
def delete_tunnel_mapping(context, conn_info):
|
||||||
|
conn_id = conn_info['id']
|
||||||
|
with context.session.begin():
|
||||||
|
sess_qry = context.session.query(IdentifierMap)
|
||||||
|
sess_qry.filter_by(ipsec_site_conn_id=conn_id).delete()
|
||||||
|
LOG.info(_("Removed mapping for connection %s"), conn_id)
|
247
neutron/services/vpn/service_drivers/cisco_ipsec.py
Normal file
247
neutron/services/vpn/service_drivers/cisco_ipsec.py
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
# Copyright 2014 Cisco Systems, Inc. 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 netaddr import core as net_exc
|
||||||
|
|
||||||
|
from neutron.common import exceptions
|
||||||
|
from neutron.common import rpc as n_rpc
|
||||||
|
from neutron.openstack.common import excutils
|
||||||
|
from neutron.openstack.common import log as logging
|
||||||
|
from neutron.openstack.common import rpc
|
||||||
|
from neutron.plugins.common import constants
|
||||||
|
from neutron.services.vpn.common import topics
|
||||||
|
from neutron.services.vpn import service_drivers
|
||||||
|
from neutron.services.vpn.service_drivers import cisco_csr_db as csr_id_map
|
||||||
|
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
IPSEC = 'ipsec'
|
||||||
|
BASE_IPSEC_VERSION = '1.0'
|
||||||
|
LIFETIME_LIMITS = {'IKE Policy': {'min': 60, 'max': 86400},
|
||||||
|
'IPSec Policy': {'min': 120, 'max': 2592000}}
|
||||||
|
MIN_CSR_MTU = 1500
|
||||||
|
MAX_CSR_MTU = 9192
|
||||||
|
|
||||||
|
|
||||||
|
class CsrValidationFailure(exceptions.BadRequest):
|
||||||
|
message = _("Cisco CSR does not support %(resource)s attribute %(key)s "
|
||||||
|
"with value '%(value)s'")
|
||||||
|
|
||||||
|
|
||||||
|
class CsrUnsupportedError(exceptions.NeutronException):
|
||||||
|
message = _("Cisco CSR does not currently support %(capability)s")
|
||||||
|
|
||||||
|
|
||||||
|
class CiscoCsrIPsecVpnDriverCallBack(object):
|
||||||
|
|
||||||
|
"""Handler for agent to plugin RPC messaging."""
|
||||||
|
|
||||||
|
# history
|
||||||
|
# 1.0 Initial version
|
||||||
|
|
||||||
|
RPC_API_VERSION = BASE_IPSEC_VERSION
|
||||||
|
|
||||||
|
def __init__(self, driver):
|
||||||
|
self.driver = driver
|
||||||
|
|
||||||
|
def create_rpc_dispatcher(self):
|
||||||
|
return n_rpc.PluginRpcDispatcher([self])
|
||||||
|
|
||||||
|
def get_vpn_services_on_host(self, context, host=None):
|
||||||
|
"""Retuns info on the vpnservices on the host."""
|
||||||
|
plugin = self.driver.service_plugin
|
||||||
|
vpnservices = plugin._get_agent_hosting_vpn_services(
|
||||||
|
context, host)
|
||||||
|
return [self.driver._make_vpnservice_dict(vpnservice, context)
|
||||||
|
for vpnservice in vpnservices]
|
||||||
|
|
||||||
|
def update_status(self, context, status):
|
||||||
|
"""Update status of all vpnservices."""
|
||||||
|
plugin = self.driver.service_plugin
|
||||||
|
plugin.update_status_by_agent(context, status)
|
||||||
|
|
||||||
|
|
||||||
|
class CiscoCsrIPsecVpnAgentApi(service_drivers.BaseIPsecVpnAgentApi):
|
||||||
|
|
||||||
|
"""API and handler for Cisco IPSec plugin to agent RPC messaging."""
|
||||||
|
|
||||||
|
RPC_API_VERSION = BASE_IPSEC_VERSION
|
||||||
|
|
||||||
|
def __init__(self, topic, default_version):
|
||||||
|
super(CiscoCsrIPsecVpnAgentApi, self).__init__(
|
||||||
|
topics.CISCO_IPSEC_AGENT_TOPIC, topic, default_version)
|
||||||
|
|
||||||
|
|
||||||
|
class CiscoCsrIPsecVPNDriver(service_drivers.VpnDriver):
|
||||||
|
|
||||||
|
"""Cisco CSR VPN Service Driver class for IPsec."""
|
||||||
|
|
||||||
|
def __init__(self, service_plugin):
|
||||||
|
super(CiscoCsrIPsecVPNDriver, self).__init__(service_plugin)
|
||||||
|
self.callbacks = CiscoCsrIPsecVpnDriverCallBack(self)
|
||||||
|
self.conn = rpc.create_connection(new=True)
|
||||||
|
self.conn.create_consumer(
|
||||||
|
topics.CISCO_IPSEC_DRIVER_TOPIC,
|
||||||
|
self.callbacks.create_rpc_dispatcher(),
|
||||||
|
fanout=False)
|
||||||
|
self.conn.consume_in_thread()
|
||||||
|
self.agent_rpc = CiscoCsrIPsecVpnAgentApi(
|
||||||
|
topics.CISCO_IPSEC_AGENT_TOPIC, BASE_IPSEC_VERSION)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def service_type(self):
|
||||||
|
return IPSEC
|
||||||
|
|
||||||
|
def validate_lifetime(self, for_policy, policy_info):
|
||||||
|
"""Ensure lifetime in secs and value is supported, based on policy."""
|
||||||
|
units = policy_info['lifetime']['units']
|
||||||
|
if units != 'seconds':
|
||||||
|
raise CsrValidationFailure(resource=for_policy,
|
||||||
|
key='lifetime:units',
|
||||||
|
value=units)
|
||||||
|
value = policy_info['lifetime']['value']
|
||||||
|
if (value < LIFETIME_LIMITS[for_policy]['min'] or
|
||||||
|
value > LIFETIME_LIMITS[for_policy]['max']):
|
||||||
|
raise CsrValidationFailure(resource=for_policy,
|
||||||
|
key='lifetime:value',
|
||||||
|
value=value)
|
||||||
|
|
||||||
|
def validate_ike_version(self, policy_info):
|
||||||
|
"""Ensure IKE policy is v1 for current REST API."""
|
||||||
|
version = policy_info['ike_version']
|
||||||
|
if version != 'v1':
|
||||||
|
raise CsrValidationFailure(resource='IKE Policy',
|
||||||
|
key='ike_version',
|
||||||
|
value=version)
|
||||||
|
|
||||||
|
def validate_mtu(self, conn_info):
|
||||||
|
"""Ensure the MTU value is supported."""
|
||||||
|
mtu = conn_info['mtu']
|
||||||
|
if mtu < MIN_CSR_MTU or mtu > MAX_CSR_MTU:
|
||||||
|
raise CsrValidationFailure(resource='IPSec Connection',
|
||||||
|
key='mtu',
|
||||||
|
value=mtu)
|
||||||
|
|
||||||
|
def validate_public_ip_present(self, vpn_service):
|
||||||
|
"""Ensure there is one gateway IP specified for the router used."""
|
||||||
|
gw_port = vpn_service.router.gw_port
|
||||||
|
if not gw_port or len(gw_port.fixed_ips) != 1:
|
||||||
|
raise CsrValidationFailure(resource='IPSec Connection',
|
||||||
|
key='router:gw_port:ip_address',
|
||||||
|
value='missing')
|
||||||
|
|
||||||
|
def validate_peer_id(self, ipsec_conn):
|
||||||
|
"""Ensure that an IP address is specified for peer ID."""
|
||||||
|
# TODO(pcm) Should we check peer_address too?
|
||||||
|
peer_id = ipsec_conn['peer_id']
|
||||||
|
try:
|
||||||
|
netaddr.IPAddress(peer_id)
|
||||||
|
except net_exc.AddrFormatError:
|
||||||
|
raise CsrValidationFailure(resource='IPSec Connection',
|
||||||
|
key='peer_id', value=peer_id)
|
||||||
|
|
||||||
|
def validate_ipsec_connection(self, context, ipsec_conn, vpn_service):
|
||||||
|
"""Validate attributes w.r.t. Cisco CSR capabilities."""
|
||||||
|
ike_policy = self.service_plugin.get_ikepolicy(
|
||||||
|
context, ipsec_conn['ikepolicy_id'])
|
||||||
|
ipsec_policy = self.service_plugin.get_ipsecpolicy(
|
||||||
|
context, ipsec_conn['ipsecpolicy_id'])
|
||||||
|
self.validate_lifetime('IKE Policy', ike_policy)
|
||||||
|
self.validate_lifetime('IPSec Policy', ipsec_policy)
|
||||||
|
self.validate_ike_version(ike_policy)
|
||||||
|
self.validate_mtu(ipsec_conn)
|
||||||
|
self.validate_public_ip_present(vpn_service)
|
||||||
|
self.validate_peer_id(ipsec_conn)
|
||||||
|
LOG.debug(_("IPSec connection %s validated for Cisco CSR"),
|
||||||
|
ipsec_conn['id'])
|
||||||
|
|
||||||
|
def create_ipsec_site_connection(self, context, ipsec_site_connection):
|
||||||
|
vpnservice = self.service_plugin._get_vpnservice(
|
||||||
|
context, ipsec_site_connection['vpnservice_id'])
|
||||||
|
try:
|
||||||
|
self.validate_ipsec_connection(context, ipsec_site_connection,
|
||||||
|
vpnservice)
|
||||||
|
except CsrValidationFailure:
|
||||||
|
with excutils.save_and_reraise_exception():
|
||||||
|
self.service_plugin.update_ipsec_site_conn_status(
|
||||||
|
context, ipsec_site_connection['id'], constants.ERROR)
|
||||||
|
csr_id_map.create_tunnel_mapping(context, ipsec_site_connection)
|
||||||
|
self.agent_rpc.vpnservice_updated(context, vpnservice['router_id'])
|
||||||
|
|
||||||
|
def update_ipsec_site_connection(
|
||||||
|
self, context, old_ipsec_site_connection, ipsec_site_connection):
|
||||||
|
capability = _("update of IPSec connections. You can delete and "
|
||||||
|
"re-add, as a workaround.")
|
||||||
|
raise CsrUnsupportedError(capability=capability)
|
||||||
|
|
||||||
|
def delete_ipsec_site_connection(self, context, ipsec_site_connection):
|
||||||
|
vpnservice = self.service_plugin._get_vpnservice(
|
||||||
|
context, ipsec_site_connection['vpnservice_id'])
|
||||||
|
self.agent_rpc.vpnservice_updated(context, vpnservice['router_id'])
|
||||||
|
|
||||||
|
def create_ikepolicy(self, context, ikepolicy):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def delete_ikepolicy(self, context, ikepolicy):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def update_ikepolicy(self, context, old_ikepolicy, ikepolicy):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def create_ipsecpolicy(self, context, ipsecpolicy):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def delete_ipsecpolicy(self, context, ipsecpolicy):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def update_ipsecpolicy(self, context, old_ipsec_policy, ipsecpolicy):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def create_vpnservice(self, context, vpnservice):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def update_vpnservice(self, context, old_vpnservice, vpnservice):
|
||||||
|
self.agent_rpc.vpnservice_updated(context, vpnservice['router_id'])
|
||||||
|
|
||||||
|
def delete_vpnservice(self, context, vpnservice):
|
||||||
|
self.agent_rpc.vpnservice_updated(context, vpnservice['router_id'])
|
||||||
|
|
||||||
|
def get_cisco_connection_mappings(self, conn_id, context):
|
||||||
|
"""Obtain persisted mappings for IDs related to connection."""
|
||||||
|
tunnel_id, ike_id, ipsec_id = csr_id_map.get_tunnel_mapping_for(
|
||||||
|
conn_id, context.session)
|
||||||
|
return {'site_conn_id': u'Tunnel%d' % tunnel_id,
|
||||||
|
'ike_policy_id': u'%d' % ike_id,
|
||||||
|
'ipsec_policy_id': u'%s' % ipsec_id}
|
||||||
|
|
||||||
|
def _make_vpnservice_dict(self, vpnservice, context):
|
||||||
|
"""Collect all info on service, including Cisco info per IPSec conn."""
|
||||||
|
vpnservice_dict = dict(vpnservice)
|
||||||
|
vpnservice_dict['ipsec_conns'] = []
|
||||||
|
vpnservice_dict['subnet'] = dict(
|
||||||
|
vpnservice.subnet)
|
||||||
|
vpnservice_dict['external_ip'] = vpnservice.router.gw_port[
|
||||||
|
'fixed_ips'][0]['ip_address']
|
||||||
|
for ipsec_conn in vpnservice.ipsec_site_connections:
|
||||||
|
ipsec_conn_dict = dict(ipsec_conn)
|
||||||
|
ipsec_conn_dict['ike_policy'] = dict(ipsec_conn.ikepolicy)
|
||||||
|
ipsec_conn_dict['ipsec_policy'] = dict(ipsec_conn.ipsecpolicy)
|
||||||
|
ipsec_conn_dict['peer_cidrs'] = [
|
||||||
|
peer_cidr.cidr for peer_cidr in ipsec_conn.peer_cidrs]
|
||||||
|
ipsec_conn_dict['cisco'] = self.get_cisco_connection_mappings(
|
||||||
|
ipsec_conn['id'], context)
|
||||||
|
vpnservice_dict['ipsec_conns'].append(ipsec_conn_dict)
|
||||||
|
return vpnservice_dict
|
@ -17,11 +17,8 @@
|
|||||||
import netaddr
|
import netaddr
|
||||||
|
|
||||||
from neutron.common import rpc as n_rpc
|
from neutron.common import rpc as n_rpc
|
||||||
from neutron import manager
|
|
||||||
from neutron.openstack.common import log as logging
|
from neutron.openstack.common import log as logging
|
||||||
from neutron.openstack.common import rpc
|
from neutron.openstack.common import rpc
|
||||||
from neutron.openstack.common.rpc import proxy
|
|
||||||
from neutron.plugins.common import constants
|
|
||||||
from neutron.services.vpn.common import topics
|
from neutron.services.vpn.common import topics
|
||||||
from neutron.services.vpn import service_drivers
|
from neutron.services.vpn import service_drivers
|
||||||
|
|
||||||
@ -60,50 +57,22 @@ class IPsecVpnDriverCallBack(object):
|
|||||||
plugin.update_status_by_agent(context, status)
|
plugin.update_status_by_agent(context, status)
|
||||||
|
|
||||||
|
|
||||||
class IPsecVpnAgentApi(proxy.RpcProxy):
|
class IPsecVpnAgentApi(service_drivers.BaseIPsecVpnAgentApi):
|
||||||
"""Agent RPC API for IPsecVPNAgent."""
|
"""Agent RPC API for IPsecVPNAgent."""
|
||||||
|
|
||||||
RPC_API_VERSION = BASE_IPSEC_VERSION
|
RPC_API_VERSION = BASE_IPSEC_VERSION
|
||||||
|
|
||||||
def _agent_notification(self, context, method, router_id,
|
def __init__(self, topic, default_version):
|
||||||
version=None):
|
super(IPsecVpnAgentApi, self).__init__(
|
||||||
"""Notify update for the agent.
|
topics.IPSEC_AGENT_TOPIC, topic, default_version)
|
||||||
|
|
||||||
This method will find where is the router, and
|
|
||||||
dispatch notification for the agent.
|
|
||||||
"""
|
|
||||||
adminContext = context.is_admin and context or context.elevated()
|
|
||||||
plugin = manager.NeutronManager.get_service_plugins().get(
|
|
||||||
constants.L3_ROUTER_NAT)
|
|
||||||
if not version:
|
|
||||||
version = self.RPC_API_VERSION
|
|
||||||
l3_agents = plugin.get_l3_agents_hosting_routers(
|
|
||||||
adminContext, [router_id],
|
|
||||||
admin_state_up=True,
|
|
||||||
active=True)
|
|
||||||
for l3_agent in l3_agents:
|
|
||||||
LOG.debug(_('Notify agent at %(topic)s.%(host)s the message '
|
|
||||||
'%(method)s'),
|
|
||||||
{'topic': topics.IPSEC_AGENT_TOPIC,
|
|
||||||
'host': l3_agent.host,
|
|
||||||
'method': method})
|
|
||||||
self.cast(
|
|
||||||
context, self.make_msg(method),
|
|
||||||
version=version,
|
|
||||||
topic='%s.%s' % (topics.IPSEC_AGENT_TOPIC, l3_agent.host))
|
|
||||||
|
|
||||||
def vpnservice_updated(self, context, router_id):
|
|
||||||
"""Send update event of vpnservices."""
|
|
||||||
method = 'vpnservice_updated'
|
|
||||||
self._agent_notification(context, method, router_id)
|
|
||||||
|
|
||||||
|
|
||||||
class IPsecVPNDriver(service_drivers.VpnDriver):
|
class IPsecVPNDriver(service_drivers.VpnDriver):
|
||||||
"""VPN Service Driver class for IPsec."""
|
"""VPN Service Driver class for IPsec."""
|
||||||
|
|
||||||
def __init__(self, service_plugin):
|
def __init__(self, service_plugin):
|
||||||
|
super(IPsecVPNDriver, self).__init__(service_plugin)
|
||||||
self.callbacks = IPsecVpnDriverCallBack(self)
|
self.callbacks = IPsecVpnDriverCallBack(self)
|
||||||
self.service_plugin = service_plugin
|
|
||||||
self.conn = rpc.create_connection(new=True)
|
self.conn = rpc.create_connection(new=True)
|
||||||
self.conn.create_consumer(
|
self.conn.create_consumer(
|
||||||
topics.IPSEC_DRIVER_TOPIC,
|
topics.IPSEC_DRIVER_TOPIC,
|
||||||
|
@ -20,6 +20,7 @@
|
|||||||
import contextlib
|
import contextlib
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from oslo.config import cfg
|
||||||
import webob.exc
|
import webob.exc
|
||||||
|
|
||||||
from neutron.api.extensions import ExtensionMiddleware
|
from neutron.api.extensions import ExtensionMiddleware
|
||||||
@ -28,6 +29,7 @@ from neutron.common import config
|
|||||||
from neutron import context
|
from neutron import context
|
||||||
from neutron.db import agentschedulers_db
|
from neutron.db import agentschedulers_db
|
||||||
from neutron.db import l3_agentschedulers_db
|
from neutron.db import l3_agentschedulers_db
|
||||||
|
from neutron.db import servicetype_db as sdb
|
||||||
from neutron.db.vpn import vpn_db
|
from neutron.db.vpn import vpn_db
|
||||||
from neutron import extensions
|
from neutron import extensions
|
||||||
from neutron.extensions import vpnaas
|
from neutron.extensions import vpnaas
|
||||||
@ -417,7 +419,20 @@ class VPNTestMixin(object):
|
|||||||
class VPNPluginDbTestCase(VPNTestMixin,
|
class VPNPluginDbTestCase(VPNTestMixin,
|
||||||
test_l3_plugin.L3NatTestCaseMixin,
|
test_l3_plugin.L3NatTestCaseMixin,
|
||||||
test_db_plugin.NeutronDbPluginV2TestCase):
|
test_db_plugin.NeutronDbPluginV2TestCase):
|
||||||
def setUp(self, core_plugin=None, vpnaas_plugin=DB_VPN_PLUGIN_KLASS):
|
def setUp(self, core_plugin=None, vpnaas_plugin=DB_VPN_PLUGIN_KLASS,
|
||||||
|
vpnaas_provider=None):
|
||||||
|
if not vpnaas_provider:
|
||||||
|
vpnaas_provider = (
|
||||||
|
constants.VPN +
|
||||||
|
':vpnaas:neutron.services.vpn.'
|
||||||
|
'service_drivers.ipsec.IPsecVPNDriver:default')
|
||||||
|
|
||||||
|
cfg.CONF.set_override('service_provider',
|
||||||
|
[vpnaas_provider],
|
||||||
|
'service_providers')
|
||||||
|
# force service type manager to reload configuration:
|
||||||
|
sdb.ServiceTypeManager._instance = None
|
||||||
|
|
||||||
service_plugins = {'vpnaas_plugin': vpnaas_plugin}
|
service_plugins = {'vpnaas_plugin': vpnaas_plugin}
|
||||||
plugin_str = ('neutron.tests.unit.db.vpn.'
|
plugin_str = ('neutron.tests.unit.db.vpn.'
|
||||||
'test_db_vpnaas.TestVpnCorePlugin')
|
'test_db_vpnaas.TestVpnCorePlugin')
|
||||||
|
@ -0,0 +1,366 @@
|
|||||||
|
# Copyright 2014 Cisco Systems, Inc. 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 mock
|
||||||
|
|
||||||
|
|
||||||
|
from neutron import context as n_ctx
|
||||||
|
from neutron.db import api as dbapi
|
||||||
|
from neutron.openstack.common import uuidutils
|
||||||
|
from neutron.plugins.common import constants
|
||||||
|
from neutron.services.vpn.service_drivers import cisco_csr_db as csr_db
|
||||||
|
from neutron.services.vpn.service_drivers import cisco_ipsec as ipsec_driver
|
||||||
|
from neutron.tests import base
|
||||||
|
|
||||||
|
_uuid = uuidutils.generate_uuid
|
||||||
|
|
||||||
|
FAKE_VPN_CONN_ID = _uuid()
|
||||||
|
|
||||||
|
FAKE_VPN_CONNECTION = {
|
||||||
|
'vpnservice_id': _uuid(),
|
||||||
|
'id': FAKE_VPN_CONN_ID,
|
||||||
|
'ikepolicy_id': _uuid(),
|
||||||
|
'ipsecpolicy_id': _uuid(),
|
||||||
|
'tenant_id': _uuid()
|
||||||
|
}
|
||||||
|
FAKE_VPN_SERVICE = {
|
||||||
|
'router_id': _uuid()
|
||||||
|
}
|
||||||
|
FAKE_HOST = 'fake_host'
|
||||||
|
|
||||||
|
|
||||||
|
class TestCiscoIPsecDriverValidation(base.BaseTestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super(TestCiscoIPsecDriverValidation, self).setUp()
|
||||||
|
self.addCleanup(mock.patch.stopall)
|
||||||
|
mock.patch('neutron.openstack.common.rpc.create_connection').start()
|
||||||
|
self.service_plugin = mock.Mock()
|
||||||
|
self.driver = ipsec_driver.CiscoCsrIPsecVPNDriver(self.service_plugin)
|
||||||
|
self.context = n_ctx.Context('some_user', 'some_tenant')
|
||||||
|
self.vpn_service = mock.Mock()
|
||||||
|
|
||||||
|
def test_ike_version_unsupported(self):
|
||||||
|
"""Failure test that Cisco CSR REST API does not support IKE v2."""
|
||||||
|
policy_info = {'ike_version': 'v2',
|
||||||
|
'lifetime': {'units': 'seconds', 'value': 60}}
|
||||||
|
self.assertRaises(ipsec_driver.CsrValidationFailure,
|
||||||
|
self.driver.validate_ike_version, policy_info)
|
||||||
|
|
||||||
|
def test_ike_lifetime_not_in_seconds(self):
|
||||||
|
"""Failure test of unsupported lifetime units for IKE policy."""
|
||||||
|
policy_info = {'lifetime': {'units': 'kilobytes', 'value': 1000}}
|
||||||
|
self.assertRaises(ipsec_driver.CsrValidationFailure,
|
||||||
|
self.driver.validate_lifetime,
|
||||||
|
"IKE Policy", policy_info)
|
||||||
|
|
||||||
|
def test_ipsec_lifetime_not_in_seconds(self):
|
||||||
|
"""Failure test of unsupported lifetime units for IPSec policy."""
|
||||||
|
policy_info = {'lifetime': {'units': 'kilobytes', 'value': 1000}}
|
||||||
|
self.assertRaises(ipsec_driver.CsrValidationFailure,
|
||||||
|
self.driver.validate_lifetime,
|
||||||
|
"IPSec Policy", policy_info)
|
||||||
|
|
||||||
|
def test_ike_lifetime_seconds_values_at_limits(self):
|
||||||
|
"""Test valid lifetime values for IKE policy."""
|
||||||
|
policy_info = {'lifetime': {'units': 'seconds', 'value': 60}}
|
||||||
|
self.driver.validate_lifetime('IKE Policy', policy_info)
|
||||||
|
policy_info = {'lifetime': {'units': 'seconds', 'value': 86400}}
|
||||||
|
self.driver.validate_lifetime('IKE Policy', policy_info)
|
||||||
|
|
||||||
|
def test_ipsec_lifetime_seconds_values_at_limits(self):
|
||||||
|
"""Test valid lifetime values for IPSec policy."""
|
||||||
|
policy_info = {'lifetime': {'units': 'seconds', 'value': 120}}
|
||||||
|
self.driver.validate_lifetime('IPSec Policy', policy_info)
|
||||||
|
policy_info = {'lifetime': {'units': 'seconds', 'value': 2592000}}
|
||||||
|
self.driver.validate_lifetime('IPSec Policy', policy_info)
|
||||||
|
|
||||||
|
def test_ike_lifetime_values_invalid(self):
|
||||||
|
"""Failure test of unsupported lifetime values for IKE policy."""
|
||||||
|
which = "IKE Policy"
|
||||||
|
policy_info = {'lifetime': {'units': 'seconds', 'value': 59}}
|
||||||
|
self.assertRaises(ipsec_driver.CsrValidationFailure,
|
||||||
|
self.driver.validate_lifetime,
|
||||||
|
which, policy_info)
|
||||||
|
policy_info = {'lifetime': {'units': 'seconds', 'value': 86401}}
|
||||||
|
self.assertRaises(ipsec_driver.CsrValidationFailure,
|
||||||
|
self.driver.validate_lifetime,
|
||||||
|
which, policy_info)
|
||||||
|
|
||||||
|
def test_ipsec_lifetime_values_invalid(self):
|
||||||
|
"""Failure test of unsupported lifetime values for IPSec policy."""
|
||||||
|
which = "IPSec Policy"
|
||||||
|
policy_info = {'lifetime': {'units': 'seconds', 'value': 119}}
|
||||||
|
self.assertRaises(ipsec_driver.CsrValidationFailure,
|
||||||
|
self.driver.validate_lifetime,
|
||||||
|
which, policy_info)
|
||||||
|
policy_info = {'lifetime': {'units': 'seconds', 'value': 2592001}}
|
||||||
|
self.assertRaises(ipsec_driver.CsrValidationFailure,
|
||||||
|
self.driver.validate_lifetime,
|
||||||
|
which, policy_info)
|
||||||
|
|
||||||
|
def test_ipsec_connection_with_mtu_at_limits(self):
|
||||||
|
"""Test IPSec site-to-site connection with MTU at limits."""
|
||||||
|
conn_info = {'mtu': 1500}
|
||||||
|
self.driver.validate_mtu(conn_info)
|
||||||
|
conn_info = {'mtu': 9192}
|
||||||
|
self.driver.validate_mtu(conn_info)
|
||||||
|
|
||||||
|
def test_ipsec_connection_with_invalid_mtu(self):
|
||||||
|
"""Failure test of IPSec site connection with unsupported MTUs."""
|
||||||
|
conn_info = {'mtu': 1499}
|
||||||
|
self.assertRaises(ipsec_driver.CsrValidationFailure,
|
||||||
|
self.driver.validate_mtu, conn_info)
|
||||||
|
conn_info = {'mtu': 9193}
|
||||||
|
self.assertRaises(ipsec_driver.CsrValidationFailure,
|
||||||
|
self.driver.validate_mtu, conn_info)
|
||||||
|
|
||||||
|
def simulate_gw_ip_available(self):
|
||||||
|
"""Helper function indicating that tunnel has a gateway IP."""
|
||||||
|
def have_one():
|
||||||
|
return 1
|
||||||
|
self.vpn_service.router.gw_port.fixed_ips.__len__ = have_one
|
||||||
|
ip_addr_mock = mock.Mock()
|
||||||
|
self.vpn_service.router.gw_port.fixed_ips = [ip_addr_mock]
|
||||||
|
return ip_addr_mock
|
||||||
|
|
||||||
|
def test_have_public_ip_for_router(self):
|
||||||
|
"""Ensure that router for IPSec connection has gateway IP."""
|
||||||
|
self.simulate_gw_ip_available()
|
||||||
|
self.driver.validate_public_ip_present(self.vpn_service)
|
||||||
|
|
||||||
|
def test_router_with_missing_gateway_ip(self):
|
||||||
|
"""Failure test of IPSec connection with missing gateway IP."""
|
||||||
|
self.simulate_gw_ip_available()
|
||||||
|
self.vpn_service.router.gw_port = None
|
||||||
|
self.assertRaises(ipsec_driver.CsrValidationFailure,
|
||||||
|
self.driver.validate_public_ip_present,
|
||||||
|
self.vpn_service)
|
||||||
|
|
||||||
|
def test_peer_id_is_an_ip_address(self):
|
||||||
|
"""Ensure peer ID is an IP address for IPsec connection create."""
|
||||||
|
ipsec_conn = {'peer_id': '10.10.10.10'}
|
||||||
|
self.driver.validate_peer_id(ipsec_conn)
|
||||||
|
|
||||||
|
def test_peer_id_is_not_ip_address(self):
|
||||||
|
"""Failure test of peer_id that is not an IP address."""
|
||||||
|
ipsec_conn = {'peer_id': 'some-site.com'}
|
||||||
|
self.assertRaises(ipsec_driver.CsrValidationFailure,
|
||||||
|
self.driver.validate_peer_id, ipsec_conn)
|
||||||
|
|
||||||
|
def test_validation_for_create_ipsec_connection(self):
|
||||||
|
"""Ensure all validation passes for IPSec site connection create."""
|
||||||
|
self.simulate_gw_ip_available()
|
||||||
|
# Provide the minimum needed items to validate
|
||||||
|
ipsec_conn = {'id': '1',
|
||||||
|
'ikepolicy_id': '123',
|
||||||
|
'ipsecpolicy_id': '2',
|
||||||
|
'mtu': 1500,
|
||||||
|
'peer_id': '10.10.10.10'}
|
||||||
|
self.service_plugin.get_ikepolicy = mock.Mock(
|
||||||
|
return_value={'ike_version': 'v1',
|
||||||
|
'lifetime': {'units': 'seconds', 'value': 60}})
|
||||||
|
self.service_plugin.get_ipsecpolicy = mock.Mock(
|
||||||
|
return_value={'lifetime': {'units': 'seconds', 'value': 120}})
|
||||||
|
self.driver.validate_ipsec_connection(self.context, ipsec_conn,
|
||||||
|
self.vpn_service)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCiscoIPsecDriverMapping(base.BaseTestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super(TestCiscoIPsecDriverMapping, self).setUp()
|
||||||
|
self.addCleanup(mock.patch.stopall)
|
||||||
|
self.context = mock.patch.object(n_ctx, 'Context').start()
|
||||||
|
self.session = self.context.session
|
||||||
|
self.query_mock = self.session.query.return_value.order_by
|
||||||
|
|
||||||
|
def test_identifying_first_mapping_id(self):
|
||||||
|
"""Make sure first available ID is obtained for each ID type."""
|
||||||
|
# Simulate mapping table is empty - get first one
|
||||||
|
self.query_mock.return_value = []
|
||||||
|
next_id = csr_db.get_next_available_tunnel_id(self.session)
|
||||||
|
self.assertEqual(0, next_id)
|
||||||
|
|
||||||
|
next_id = csr_db.get_next_available_ike_policy_id(self.session)
|
||||||
|
self.assertEqual(1, next_id)
|
||||||
|
|
||||||
|
next_id = csr_db.get_next_available_ipsec_policy_id(self.session)
|
||||||
|
self.assertEqual(1, next_id)
|
||||||
|
|
||||||
|
def test_last_mapping_id_available(self):
|
||||||
|
"""Make sure can get the last ID for each of the table types."""
|
||||||
|
# Simulate query indicates table is full
|
||||||
|
self.query_mock.return_value = [
|
||||||
|
(x, ) for x in xrange(csr_db.MAX_CSR_TUNNELS - 1)]
|
||||||
|
next_id = csr_db.get_next_available_tunnel_id(self.session)
|
||||||
|
self.assertEqual(csr_db.MAX_CSR_TUNNELS - 1, next_id)
|
||||||
|
|
||||||
|
self.query_mock.return_value = [
|
||||||
|
(x, ) for x in xrange(1, csr_db.MAX_CSR_IKE_POLICIES)]
|
||||||
|
next_id = csr_db.get_next_available_ike_policy_id(self.session)
|
||||||
|
self.assertEqual(csr_db.MAX_CSR_IKE_POLICIES, next_id)
|
||||||
|
|
||||||
|
self.query_mock.return_value = [
|
||||||
|
(x, ) for x in xrange(1, csr_db.MAX_CSR_IPSEC_POLICIES)]
|
||||||
|
next_id = csr_db.get_next_available_ipsec_policy_id(self.session)
|
||||||
|
self.assertEqual(csr_db.MAX_CSR_IPSEC_POLICIES, next_id)
|
||||||
|
|
||||||
|
def test_reusing_first_available_mapping_id(self):
|
||||||
|
"""Ensure that we reuse the first available ID.
|
||||||
|
|
||||||
|
Make sure that the next lowest ID is obtained from the mapping
|
||||||
|
table when there are "holes" from deletions. Database query sorts
|
||||||
|
the entries, so will return them in order. Using tunnel ID, as the
|
||||||
|
logic is the same for each ID type.
|
||||||
|
"""
|
||||||
|
self.query_mock.return_value = [(0, ), (1, ), (2, ), (5, ), (6, )]
|
||||||
|
next_id = csr_db.get_next_available_tunnel_id(self.session)
|
||||||
|
self.assertEqual(3, next_id)
|
||||||
|
|
||||||
|
def test_no_more_mapping_ids_available(self):
|
||||||
|
"""Failure test of trying to reserve ID, when none available."""
|
||||||
|
self.query_mock.return_value = [
|
||||||
|
(x, ) for x in xrange(csr_db.MAX_CSR_TUNNELS)]
|
||||||
|
self.assertRaises(IndexError, csr_db.get_next_available_tunnel_id,
|
||||||
|
self.session)
|
||||||
|
|
||||||
|
self.query_mock.return_value = [
|
||||||
|
(x, ) for x in xrange(1, csr_db.MAX_CSR_IKE_POLICIES + 1)]
|
||||||
|
self.assertRaises(IndexError, csr_db.get_next_available_ike_policy_id,
|
||||||
|
self.session)
|
||||||
|
|
||||||
|
self.query_mock.return_value = [
|
||||||
|
(x, ) for x in xrange(1, csr_db.MAX_CSR_IPSEC_POLICIES + 1)]
|
||||||
|
self.assertRaises(IndexError,
|
||||||
|
csr_db.get_next_available_ipsec_policy_id,
|
||||||
|
self.session)
|
||||||
|
|
||||||
|
def test_create_tunnel_mappings(self):
|
||||||
|
"""Ensure successfully create new tunnel mappings."""
|
||||||
|
# Simulate that first IDs are obtained
|
||||||
|
self.query_mock.return_value = []
|
||||||
|
map_db_mock = mock.patch.object(csr_db, 'IdentifierMap').start()
|
||||||
|
conn_info = {'ikepolicy_id': '10',
|
||||||
|
'ipsecpolicy_id': '50',
|
||||||
|
'id': '100',
|
||||||
|
'tenant_id': '1000'}
|
||||||
|
csr_db.create_tunnel_mapping(self.context, conn_info)
|
||||||
|
map_db_mock.assert_called_once_with(csr_tunnel_id=0,
|
||||||
|
csr_ike_policy_id=1,
|
||||||
|
csr_ipsec_policy_id=1,
|
||||||
|
ipsec_site_conn_id='100',
|
||||||
|
tenant_id='1000')
|
||||||
|
# Create another, with next ID of 2 for all IDs (not mocking each
|
||||||
|
# ID separately, so will not have different IDs).
|
||||||
|
self.query_mock.return_value = [(0, ), (1, )]
|
||||||
|
map_db_mock.reset_mock()
|
||||||
|
conn_info = {'ikepolicy_id': '20',
|
||||||
|
'ipsecpolicy_id': '60',
|
||||||
|
'id': '101',
|
||||||
|
'tenant_id': '1000'}
|
||||||
|
csr_db.create_tunnel_mapping(self.context, conn_info)
|
||||||
|
map_db_mock.assert_called_once_with(csr_tunnel_id=2,
|
||||||
|
csr_ike_policy_id=2,
|
||||||
|
csr_ipsec_policy_id=2,
|
||||||
|
ipsec_site_conn_id='101',
|
||||||
|
tenant_id='1000')
|
||||||
|
|
||||||
|
|
||||||
|
class TestCiscoIPsecDriver(base.BaseTestCase):
|
||||||
|
|
||||||
|
"""Test that various incoming requests are sent to device driver."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super(TestCiscoIPsecDriver, self).setUp()
|
||||||
|
self.addCleanup(mock.patch.stopall)
|
||||||
|
dbapi.configure_db()
|
||||||
|
self.addCleanup(dbapi.clear_db)
|
||||||
|
mock.patch('neutron.openstack.common.rpc.create_connection').start()
|
||||||
|
|
||||||
|
l3_agent = mock.Mock()
|
||||||
|
l3_agent.host = FAKE_HOST
|
||||||
|
plugin = mock.Mock()
|
||||||
|
plugin.get_l3_agents_hosting_routers.return_value = [l3_agent]
|
||||||
|
plugin_p = mock.patch('neutron.manager.NeutronManager.get_plugin')
|
||||||
|
get_plugin = plugin_p.start()
|
||||||
|
get_plugin.return_value = plugin
|
||||||
|
service_plugin_p = mock.patch(
|
||||||
|
'neutron.manager.NeutronManager.get_service_plugins')
|
||||||
|
get_service_plugin = service_plugin_p.start()
|
||||||
|
get_service_plugin.return_value = {constants.L3_ROUTER_NAT: plugin}
|
||||||
|
|
||||||
|
service_plugin = mock.Mock()
|
||||||
|
service_plugin.get_l3_agents_hosting_routers.return_value = [l3_agent]
|
||||||
|
service_plugin._get_vpnservice.return_value = {
|
||||||
|
'router_id': _uuid()
|
||||||
|
}
|
||||||
|
self.db_update_mock = service_plugin.update_ipsec_site_conn_status
|
||||||
|
self.driver = ipsec_driver.CiscoCsrIPsecVPNDriver(service_plugin)
|
||||||
|
self.driver.validate_ipsec_connection = mock.Mock()
|
||||||
|
mock.patch.object(csr_db, 'create_tunnel_mapping').start()
|
||||||
|
self.context = n_ctx.Context('some_user', 'some_tenant')
|
||||||
|
|
||||||
|
def _test_update(self, func, args):
|
||||||
|
with mock.patch.object(self.driver.agent_rpc, 'cast') as cast:
|
||||||
|
func(self.context, *args)
|
||||||
|
cast.assert_called_once_with(
|
||||||
|
self.context,
|
||||||
|
{'args': {},
|
||||||
|
'namespace': None,
|
||||||
|
'method': 'vpnservice_updated'},
|
||||||
|
version='1.0',
|
||||||
|
topic='cisco_csr_ipsec_agent.fake_host')
|
||||||
|
|
||||||
|
def test_create_ipsec_site_connection(self):
|
||||||
|
self._test_update(self.driver.create_ipsec_site_connection,
|
||||||
|
[FAKE_VPN_CONNECTION])
|
||||||
|
|
||||||
|
def test_failure_validation_ipsec_connection(self):
|
||||||
|
"""Failure test of validation during IPSec site connection create.
|
||||||
|
|
||||||
|
Simulate a validation failure, and ensure that database is
|
||||||
|
updated to indicate connection is in error state.
|
||||||
|
|
||||||
|
TODO(pcm): FUTURE - remove test case, once vendor plugin
|
||||||
|
validation is done before database commit.
|
||||||
|
"""
|
||||||
|
self.driver.validate_ipsec_connection.side_effect = (
|
||||||
|
ipsec_driver.CsrValidationFailure(resource='IPSec Connection',
|
||||||
|
key='mtu', value=1000))
|
||||||
|
self.assertRaises(ipsec_driver.CsrValidationFailure,
|
||||||
|
self.driver.create_ipsec_site_connection,
|
||||||
|
self.context, FAKE_VPN_CONNECTION)
|
||||||
|
self.db_update_mock.assert_called_with(self.context,
|
||||||
|
FAKE_VPN_CONN_ID,
|
||||||
|
constants.ERROR)
|
||||||
|
|
||||||
|
def test_update_ipsec_site_connection(self):
|
||||||
|
# TODO(pcm) FUTURE - Update test, when supported
|
||||||
|
self.assertRaises(ipsec_driver.CsrUnsupportedError,
|
||||||
|
self._test_update,
|
||||||
|
self.driver.update_ipsec_site_connection,
|
||||||
|
[FAKE_VPN_CONNECTION, FAKE_VPN_CONNECTION])
|
||||||
|
|
||||||
|
def test_delete_ipsec_site_connection(self):
|
||||||
|
self._test_update(self.driver.delete_ipsec_site_connection,
|
||||||
|
[FAKE_VPN_CONNECTION])
|
||||||
|
|
||||||
|
def test_update_vpnservice(self):
|
||||||
|
self._test_update(self.driver.update_vpnservice,
|
||||||
|
[FAKE_VPN_SERVICE, FAKE_VPN_SERVICE])
|
||||||
|
|
||||||
|
def test_delete_vpnservice(self):
|
||||||
|
self._test_update(self.driver.delete_vpnservice,
|
||||||
|
[FAKE_VPN_SERVICE])
|
Loading…
Reference in New Issue
Block a user