]> review.fuel-infra Code Review - openstack-build/neutron-build.git/commitdiff
VPNaaS Device Driver for Cisco CSR
authorPaul Michali <pcm@cisco.com>
Mon, 17 Feb 2014 20:56:22 +0000 (15:56 -0500)
committerGerrit Code Review <review@openstack.org>
Tue, 11 Mar 2014 18:12:55 +0000 (18:12 +0000)
This is the device driver for the vendor specific VPNaaS plugin. This
change relies on the service driver code (review 74144), which is also
out for review.

Note: Support for sharing of IKE/IPSec policies (which is currently
      prevented by the service driver code), will be done as a later
      enhancement.
Note: Needs Tempest tests updated/created to test this.
Note: To run, this needs an out-of-band Cisco CSR installed and
      configured.
Note: This uses a newer version of requests library and a new httmock
      library. Until these are approved (75296), the UT will be
      renamed to prevent testing the REST client API to the CSR.

Change-Id: I4f73f7fa1bfcdc89a35ffe63dd253f8eede98485
Paritally-Implements: blueprint vpnaas-cisco-driver

etc/neutron/plugins/cisco/cisco_vpn_agent.ini [new file with mode: 0644]
neutron/services/vpn/device_drivers/cisco_csr_rest_client.py [new file with mode: 0644]
neutron/services/vpn/device_drivers/cisco_ipsec.py [new file with mode: 0644]
neutron/services/vpn/service_drivers/cisco_csr_db.py
neutron/tests/unit/services/vpn/device_drivers/cisco_csr_mock.py [new file with mode: 0644]
neutron/tests/unit/services/vpn/device_drivers/notest_cisco_csr_rest.py [new file with mode: 0644]
neutron/tests/unit/services/vpn/device_drivers/test_cisco_ipsec.py [new file with mode: 0644]

diff --git a/etc/neutron/plugins/cisco/cisco_vpn_agent.ini b/etc/neutron/plugins/cisco/cisco_vpn_agent.ini
new file mode 100644 (file)
index 0000000..d15069b
--- /dev/null
@@ -0,0 +1,22 @@
+[cisco_csr_ipsec]
+# Status check interval in seconds, for VPNaaS IPSec connections used on CSR
+# status_check_interval = 60
+
+# Cisco CSR management port information for REST access used by VPNaaS
+# TODO(pcm): Remove once CSR is integrated in as a Neutron router.
+#
+# Format is:
+# [cisco_csr_rest:<public IP>]
+# rest_mgmt = <mgmt port IP>
+# tunnel_ip = <tunnel IP>
+# username = <user>
+# password = <password>
+# timeout = <timeout>
+#
+# where:
+#   public IP ----- Public IP address of router used with a VPN service (1:1 with CSR)
+#   tunnel IP ----- Public IP address of the CSR used for the IPSec tunnel
+#   mgmt port IP -- IP address of CSR for REST API access (not console port)
+#   user ---------- Username for REST management port access to Cisco CSR
+#   password ------ Password for REST management port access to Cisco CSR
+#   timeout ------- REST request timeout to Cisco CSR (optional)
diff --git a/neutron/services/vpn/device_drivers/cisco_csr_rest_client.py b/neutron/services/vpn/device_drivers/cisco_csr_rest_client.py
new file mode 100644 (file)
index 0000000..3cc3c43
--- /dev/null
@@ -0,0 +1,254 @@
+# 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.
+#
+# @author: Paul Michali, Cisco Systems, Inc.
+
+import time
+
+import netaddr
+import requests
+from requests import exceptions as r_exc
+
+from neutron.openstack.common import jsonutils
+from neutron.openstack.common import log as logging
+
+
+TIMEOUT = 20.0
+
+LOG = logging.getLogger(__name__)
+HEADER_CONTENT_TYPE_JSON = {'content-type': 'application/json'}
+URL_BASE = 'https://%(host)s/api/v1/%(resource)s'
+
+
+def make_route_id(cidr, interface):
+    """Build ID that will be used to identify route for later deletion."""
+    net = netaddr.IPNetwork(cidr)
+    return '%(network)s_%(prefix)s_%(interface)s' % {
+        'network': net.network,
+        'prefix': net.prefixlen,
+        'interface': interface}
+
+
+class CsrRestClient(object):
+
+    """REST CsrRestClient for accessing the Cisco Cloud Services Router."""
+
+    def __init__(self, host, tunnel_ip, username, password, timeout=None):
+        self.host = host
+        self.tunnel_ip = tunnel_ip
+        self.auth = (username, password)
+        self.token = None
+        self.status = requests.codes.OK
+        self.timeout = timeout
+        self.max_tries = 5
+        self.session = requests.Session()
+
+    def _response_info_for(self, response, method):
+        """Return contents or location from response.
+
+        For a POST or GET with a 200 response, the response content
+        is returned.
+
+        For a POST with a 201 response, return the header's location,
+        which contains the identifier for the created resource.
+
+        If there is an error, return the response content, so that
+        it can be used in error processing ('error-code', 'error-message',
+        and 'detail' fields).
+        """
+        if method in ('POST', 'GET') and self.status == requests.codes.OK:
+            LOG.debug(_('RESPONSE: %s'), response.json())
+            return response.json()
+        if method == 'POST' and self.status == requests.codes.CREATED:
+            return response.headers.get('location', '')
+        if self.status >= requests.codes.BAD_REQUEST and response.content:
+            if 'error-code' in response.content:
+                content = jsonutils.loads(response.content)
+                LOG.debug("Error response content %s", content)
+                return content
+
+    def _request(self, method, url, **kwargs):
+        """Perform REST request and save response info."""
+        try:
+            LOG.debug(_("%(method)s: Request for %(resource)s payload: "
+                        "%(payload)s"),
+                      {'method': method.upper(), 'resource': url,
+                       'payload': kwargs.get('data')})
+            start_time = time.time()
+            response = self.session.request(method, url, verify=False,
+                                            timeout=self.timeout, **kwargs)
+            LOG.debug(_("%(method)s Took %(time).2f seconds to process"),
+                      {'method': method.upper(),
+                       'time': time.time() - start_time})
+        except (r_exc.Timeout, r_exc.SSLError) as te:
+            # Should never see SSLError, unless requests package is old (<2.0)
+            timeout_val = 0.0 if self.timeout is None else self.timeout
+            LOG.warning(_("%(method)s: Request timeout%(ssl)s "
+                          "(%(timeout).3f sec) for CSR(%(host)s)"),
+                        {'method': method,
+                         'timeout': timeout_val,
+                         'ssl': '(SSLError)'
+                         if isinstance(te, r_exc.SSLError) else '',
+                         'host': self.host})
+            self.status = requests.codes.REQUEST_TIMEOUT
+        except r_exc.ConnectionError as ce:
+            LOG.error(_("%(method)s: Unable to connect to CSR(%(host)s): "
+                        "%(error)s"),
+                      {'method': method, 'host': self.host, 'error': ce})
+            self.status = requests.codes.NOT_FOUND
+        except Exception as e:
+            LOG.error(_("%(method)s: Unexpected error for CSR (%(host)s): "
+                        "%(error)s"),
+                      {'method': method, 'host': self.host, 'error': e})
+            self.status = requests.codes.INTERNAL_SERVER_ERROR
+        else:
+            self.status = response.status_code
+            LOG.debug(_("%(method)s: Completed [%(status)s]"),
+                      {'method': method, 'status': self.status})
+            return self._response_info_for(response, method)
+
+    def authenticate(self):
+        """Obtain a token to use for subsequent CSR REST requests.
+
+        This is called when there is no token yet, or if the token has expired
+        and attempts to use it resulted in an UNAUTHORIZED REST response.
+        """
+
+        url = URL_BASE % {'host': self.host, 'resource': 'auth/token-services'}
+        headers = {'Content-Length': '0',
+                   'Accept': 'application/json'}
+        headers.update(HEADER_CONTENT_TYPE_JSON)
+        LOG.debug(_("%(auth)s with CSR %(host)s"),
+                  {'auth': 'Authenticating' if self.token is None
+                   else 'Reauthenticating', 'host': self.host})
+        self.token = None
+        response = self._request("POST", url, headers=headers, auth=self.auth)
+        if response:
+            self.token = response['token-id']
+            LOG.debug(_("Successfully authenticated with CSR %s"), self.host)
+            return True
+        LOG.error(_("Failed authentication with CSR %(host)s [%(status)s]"),
+                  {'host': self.host, 'status': self.status})
+
+    def _do_request(self, method, resource, payload=None, more_headers=None,
+                    full_url=False):
+        """Perform a REST request to a CSR resource.
+
+        If this is the first time interacting with the CSR, a token will
+        be obtained. If the request fails, due to an expired token, the
+        token will be obtained and the request will be retried once more.
+        """
+
+        if self.token is None:
+            if not self.authenticate():
+                return
+
+        if full_url:
+            url = resource
+        else:
+            url = ('https://%(host)s/api/v1/%(resource)s' %
+                   {'host': self.host, 'resource': resource})
+        headers = {'Accept': 'application/json', 'X-auth-token': self.token}
+        if more_headers:
+            headers.update(more_headers)
+        if payload:
+            payload = jsonutils.dumps(payload)
+        response = self._request(method, url, data=payload, headers=headers)
+        if self.status == requests.codes.UNAUTHORIZED:
+            if not self.authenticate():
+                return
+            headers['X-auth-token'] = self.token
+            response = self._request(method, url, data=payload,
+                                     headers=headers)
+        if self.status != requests.codes.REQUEST_TIMEOUT:
+            return response
+        LOG.error(_("%(method)s: Request timeout for CSR(%(host)s)"),
+                  {'method': method, 'host': self.host})
+
+    def get_request(self, resource, full_url=False):
+        """Perform a REST GET requests for a CSR resource."""
+        return self._do_request('GET', resource, full_url=full_url)
+
+    def post_request(self, resource, payload=None):
+        """Perform a POST request to a CSR resource."""
+        return self._do_request('POST', resource, payload=payload,
+                                more_headers=HEADER_CONTENT_TYPE_JSON)
+
+    def put_request(self, resource, payload=None):
+        """Perform a PUT request to a CSR resource."""
+        return self._do_request('PUT', resource, payload=payload,
+                                more_headers=HEADER_CONTENT_TYPE_JSON)
+
+    def delete_request(self, resource):
+        """Perform a DELETE request on a CSR resource."""
+        return self._do_request('DELETE', resource,
+                                more_headers=HEADER_CONTENT_TYPE_JSON)
+
+    def create_ike_policy(self, policy_info):
+        base_ike_policy_info = {u'version': u'v1',
+                                u'local-auth-method': u'pre-share'}
+        base_ike_policy_info.update(policy_info)
+        return self.post_request('vpn-svc/ike/policies',
+                                 payload=base_ike_policy_info)
+
+    def create_ipsec_policy(self, policy_info):
+        base_ipsec_policy_info = {u'mode': u'tunnel'}
+        base_ipsec_policy_info.update(policy_info)
+        return self.post_request('vpn-svc/ipsec/policies',
+                                 payload=base_ipsec_policy_info)
+
+    def create_pre_shared_key(self, psk_info):
+        return self.post_request('vpn-svc/ike/keyrings', payload=psk_info)
+
+    def create_ipsec_connection(self, connection_info):
+        base_conn_info = {u'vpn-type': u'site-to-site',
+                          u'ip-version': u'ipv4'}
+        connection_info.update(base_conn_info)
+        # TODO(pcm) pass in value, when CSR is embedded as Neutron router.
+        # Currently, get this from .INI file.
+        connection_info[u'local-device'][u'tunnel-ip-address'] = self.tunnel_ip
+        return self.post_request('vpn-svc/site-to-site',
+                                 payload=connection_info)
+
+    def configure_ike_keepalive(self, keepalive_info):
+        base_keepalive_info = {u'periodic': True}
+        keepalive_info.update(base_keepalive_info)
+        return self.put_request('vpn-svc/ike/keepalive', keepalive_info)
+
+    def create_static_route(self, route_info):
+        return self.post_request('routing-svc/static-routes',
+                                 payload=route_info)
+
+    def delete_static_route(self, route_id):
+        return self.delete_request('routing-svc/static-routes/%s' % route_id)
+
+    def delete_ipsec_connection(self, conn_id):
+        return self.delete_request('vpn-svc/site-to-site/%s' % conn_id)
+
+    def delete_ipsec_policy(self, policy_id):
+        return self.delete_request('vpn-svc/ipsec/policies/%s' % policy_id)
+
+    def delete_ike_policy(self, policy_id):
+        return self.delete_request('vpn-svc/ike/policies/%s' % policy_id)
+
+    def delete_pre_shared_key(self, key_id):
+        return self.delete_request('vpn-svc/ike/keyrings/%s' % key_id)
+
+    def read_tunnel_statuses(self):
+        results = self.get_request('vpn-svc/site-to-site/active/sessions')
+        if self.status != requests.codes.OK or not results:
+            return []
+        tunnels = [(t[u'vpn-interface-name'], t[u'status'])
+                   for t in results['items']]
+        return tunnels
diff --git a/neutron/services/vpn/device_drivers/cisco_ipsec.py b/neutron/services/vpn/device_drivers/cisco_ipsec.py
new file mode 100644 (file)
index 0000000..c518491
--- /dev/null
@@ -0,0 +1,795 @@
+# 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.
+#
+# @author: Paul Michali, Cisco Systems, Inc.
+
+import abc
+from collections import namedtuple
+import httplib
+
+import netaddr
+from oslo.config import cfg
+
+from neutron.common import exceptions
+from neutron.common import rpc as n_rpc
+from neutron import context as ctx
+from neutron.openstack.common import lockutils
+from neutron.openstack.common import log as logging
+from neutron.openstack.common import loopingcall
+from neutron.openstack.common import rpc
+from neutron.openstack.common.rpc import proxy
+from neutron.plugins.common import constants
+from neutron.plugins.common import utils as plugin_utils
+from neutron.services.vpn.common import topics
+from neutron.services.vpn import device_drivers
+from neutron.services.vpn.device_drivers import (
+    cisco_csr_rest_client as csr_client)
+
+
+ipsec_opts = [
+    cfg.IntOpt('status_check_interval',
+               default=60,
+               help=_("Status check interval for Cisco CSR IPSec connections"))
+]
+cfg.CONF.register_opts(ipsec_opts, 'cisco_csr_ipsec')
+
+LOG = logging.getLogger(__name__)
+
+RollbackStep = namedtuple('RollbackStep', ['action', 'resource_id', 'title'])
+
+
+class CsrResourceCreateFailure(exceptions.NeutronException):
+    message = _("Cisco CSR failed to create %(resource)s (%(which)s)")
+
+
+class CsrDriverMismatchError(exceptions.NeutronException):
+    message = _("Required %(resource)s attribute %(attr)s mapping for Cisco "
+                "CSR is missing in device driver")
+
+
+class CsrUnknownMappingError(exceptions.NeutronException):
+    message = _("Device driver does not have a mapping of '%(value)s for "
+                "attribute %(attr)s of %(resource)s")
+
+
+def find_available_csrs_from_config(config_files):
+    """Read INI for available Cisco CSRs that driver can use.
+
+    Loads management port, tunnel IP, user, and password information for
+    available CSRs from configuration file. Driver will use this info to
+    configure VPN connections. The CSR is associated 1:1 with a Neutron
+    router. To identify which CSR to use for a VPN service, the public
+    (GW) IP of the Neutron router will be used as an index into the CSR
+    config info.
+    """
+    multi_parser = cfg.MultiConfigParser()
+    LOG.info(_("Scanning config files %s for Cisco CSR configurations"),
+             config_files)
+    try:
+        read_ok = multi_parser.read(config_files)
+    except cfg.ParseError as pe:
+        LOG.error(_("Config file parse error: %s"), pe)
+        return {}
+
+    if len(read_ok) != len(config_files):
+        raise cfg.Error(_("Unable to parse config files %s for Cisco CSR "
+                          "info") % config_files)
+    csrs_found = {}
+    for parsed_file in multi_parser.parsed:
+        for parsed_item in parsed_file.keys():
+            device_type, sep, for_router = parsed_item.partition(':')
+            if device_type.lower() == 'cisco_csr_rest':
+                try:
+                    netaddr.IPNetwork(for_router)
+                except netaddr.core.AddrFormatError:
+                    LOG.error(_("Ignoring Cisco CSR configuration entry - "
+                                "router IP %s is not valid"), for_router)
+                    continue
+                entry = parsed_file[parsed_item]
+                # Check for missing fields
+                try:
+                    rest_mgmt_ip = entry['rest_mgmt'][0]
+                    tunnel_ip = entry['tunnel_ip'][0]
+                    username = entry['username'][0]
+                    password = entry['password'][0]
+                except KeyError as ke:
+                    LOG.error(_("Ignoring Cisco CSR for router %(router)s "
+                                "- missing %(field)s setting"),
+                              {'router': for_router, 'field': str(ke)})
+                    continue
+                # Validate fields
+                try:
+                    timeout = float(entry['timeout'][0])
+                except ValueError:
+                    LOG.error(_("Ignoring Cisco CSR for router %s - "
+                                "timeout is not a floating point number"),
+                              for_router)
+                    continue
+                except KeyError:
+                    timeout = csr_client.TIMEOUT
+                try:
+                    netaddr.IPAddress(rest_mgmt_ip)
+                except netaddr.core.AddrFormatError:
+                    LOG.error(_("Ignoring Cisco CSR for subnet %s - "
+                                "REST management is not an IP address"),
+                              for_router)
+                    continue
+                try:
+                    netaddr.IPAddress(tunnel_ip)
+                except netaddr.core.AddrFormatError:
+                    LOG.error(_("Ignoring Cisco CSR for router %s - "
+                                "local tunnel is not an IP address"),
+                              for_router)
+                    continue
+                csrs_found[for_router] = {'rest_mgmt': rest_mgmt_ip,
+                                          'tunnel_ip': tunnel_ip,
+                                          'username': username,
+                                          'password': password,
+                                          'timeout': timeout}
+
+                LOG.debug(_("Found CSR for router %(router)s: %(info)s"),
+                          {'router': for_router,
+                           'info': csrs_found[for_router]})
+    return csrs_found
+
+
+class CiscoCsrIPsecVpnDriverApi(proxy.RpcProxy):
+    """RPC API for agent to plugin messaging."""
+
+    def get_vpn_services_on_host(self, context, host):
+        """Get list of vpnservices on this host.
+
+        The vpnservices including related ipsec_site_connection,
+        ikepolicy, ipsecpolicy, and Cisco info on this host.
+        """
+        return self.call(context,
+                         self.make_msg('get_vpn_services_on_host',
+                                       host=host),
+                         topic=self.topic)
+
+    def update_status(self, context, status):
+        """Update status for all VPN services and connections."""
+        return self.cast(context,
+                         self.make_msg('update_status',
+                                       status=status),
+                         topic=self.topic)
+
+
+class CiscoCsrIPsecDriver(device_drivers.DeviceDriver):
+    """Cisco CSR VPN Device Driver for IPSec.
+
+    This class is designed for use with L3-agent now.
+    However this driver will be used with another agent in future.
+    so the use of "Router" is kept minimul now.
+    Insted of router_id,  we are using process_id in this code.
+    """
+
+    # history
+    #   1.0 Initial version
+
+    RPC_API_VERSION = '1.0'
+    __metaclass__ = abc.ABCMeta
+
+    def __init__(self, agent, host):
+        self.host = host
+        self.conn = rpc.create_connection(new=True)
+        context = ctx.get_admin_context_without_session()
+        node_topic = '%s.%s' % (topics.CISCO_IPSEC_AGENT_TOPIC, self.host)
+
+        self.service_state = {}
+
+        self.conn.create_consumer(
+            node_topic,
+            self.create_rpc_dispatcher(),
+            fanout=False)
+        self.conn.consume_in_thread()
+        self.agent_rpc = (
+            CiscoCsrIPsecVpnDriverApi(topics.CISCO_IPSEC_DRIVER_TOPIC, '1.0'))
+        self.periodic_report = loopingcall.FixedIntervalLoopingCall(
+            self.report_status, context)
+        self.periodic_report.start(
+            interval=agent.conf.cisco_csr_ipsec.status_check_interval)
+
+        csrs_found = find_available_csrs_from_config(cfg.CONF.config_file)
+        if csrs_found:
+            LOG.info(_("Loaded %(num)d Cisco CSR configuration%(plural)s"),
+                     {'num': len(csrs_found),
+                      'plural': 's'[len(csrs_found) == 1:]})
+        else:
+            raise SystemExit(_('No Cisco CSR configurations found in: %s') %
+                             cfg.CONF.config_file)
+        self.csrs = dict([(k, csr_client.CsrRestClient(v['rest_mgmt'],
+                                                       v['tunnel_ip'],
+                                                       v['username'],
+                                                       v['password'],
+                                                       v['timeout']))
+                          for k, v in csrs_found.items()])
+
+    def create_rpc_dispatcher(self):
+        return n_rpc.PluginRpcDispatcher([self])
+
+    def vpnservice_updated(self, context, **kwargs):
+        """Handle VPNaaS service driver change notifications."""
+        LOG.debug(_("Handling VPN service update notification"))
+        self.sync(context, [])
+
+    def create_vpn_service(self, service_data):
+        """Create new entry to track VPN service and its connections."""
+        vpn_service_id = service_data['id']
+        vpn_service_router = service_data['external_ip']
+        self.service_state[vpn_service_id] = CiscoCsrVpnService(
+            service_data, self.csrs.get(vpn_service_router))
+        return self.service_state[vpn_service_id]
+
+    def update_connection(self, context, vpn_service_id, conn_data):
+        """Handle notification for a single IPSec connection."""
+        vpn_service = self.service_state[vpn_service_id]
+        conn_id = conn_data['id']
+        is_admin_up = conn_data[u'admin_state_up']
+        if conn_id in vpn_service.conn_state:
+            ipsec_conn = vpn_service.conn_state[conn_id]
+            ipsec_conn.last_status = conn_data['status']
+            if is_admin_up:
+                ipsec_conn.is_dirty = False
+                LOG.debug(_("Update: IPSec connection %s unchanged - "
+                            "marking clean"), conn_id)
+                # TODO(pcm) FUTURE - Handle update requests (delete/create?)
+                # will need to detect what has changed. For now assume no
+                # change (it is blocked in service driver).
+            else:
+                LOG.debug(_("Update: IPSec connection %s is admin down - "
+                            "will be removed in sweep phase"), conn_id)
+        else:
+            if not is_admin_up:
+                LOG.debug(_("Update: Unknown IPSec connection %s is admin "
+                            "down - ignoring"), conn_id)
+                return
+            LOG.debug(_("Update: New IPSec connection %s - marking clean"),
+                      conn_id)
+            ipsec_conn = vpn_service.create_connection(conn_data)
+            ipsec_conn.create_ipsec_site_connection(context, conn_data)
+        return ipsec_conn
+
+    def update_service(self, context, service_data):
+        """Handle notification for a single VPN Service and its connections."""
+        vpn_service_id = service_data['id']
+        csr_id = service_data['external_ip']
+        if csr_id not in self.csrs:
+            LOG.error(_("Update: Skipping VPN service %(service)s as it's "
+                        "router (%(csr_id)s is not associated with a Cisco "
+                        "CSR"), {'service': vpn_service_id, 'csr_id': csr_id})
+            return
+        is_admin_up = service_data[u'admin_state_up']
+        if vpn_service_id in self.service_state:
+            vpn_service = self.service_state[vpn_service_id]
+            vpn_service.last_status = service_data['status']
+            if is_admin_up:
+                vpn_service.is_dirty = False
+            else:
+                LOG.debug(_("Update: VPN service %s is admin down - will "
+                            "be removed in sweep phase"), vpn_service_id)
+                return vpn_service
+        else:
+            if not is_admin_up:
+                LOG.debug(_("Update: Unknown VPN service %s is admin down - "
+                            "ignoring"), vpn_service_id)
+                return
+            vpn_service = self.create_vpn_service(service_data)
+        # Handle all the IPSec connection notifications in the data
+        LOG.debug(_("Update: Processing IPSec connections for VPN service %s"),
+                  vpn_service_id)
+        for conn_data in service_data['ipsec_conns']:
+            self.update_connection(context, service_data['id'], conn_data)
+        LOG.debug(_("Update: Completed update processing"))
+        return vpn_service
+
+    def update_all_services_and_connections(self, context):
+        """Update services and connections based on plugin info.
+
+        Perform any create and update operations and then update status.
+        Mark every visited connection as no longer "dirty" so they will
+        not be deleted at end of sync processing.
+        """
+        services_data = self.agent_rpc.get_vpn_services_on_host(context,
+                                                                self.host)
+        LOG.debug("Sync updating for %d VPN services", len(services_data))
+        vpn_services = []
+        for service_data in services_data:
+            vpn_service = self.update_service(context, service_data)
+            if vpn_service:
+                vpn_services.append(vpn_service)
+        return vpn_services
+
+    def mark_existing_connections_as_dirty(self):
+        """Mark all existing connections as "dirty" for sync."""
+        service_count = 0
+        connection_count = 0
+        for service_state in self.service_state.values():
+            service_state.is_dirty = True
+            service_count += 1
+            for conn_id in service_state.conn_state:
+                service_state.conn_state[conn_id].is_dirty = True
+                connection_count += 1
+        LOG.debug(_("Mark: %(service)d VPN services and %(conn)d IPSec "
+                    "connections marked dirty"), {'service': service_count,
+                                                  'conn': connection_count})
+
+    def remove_unknown_connections(self, context):
+        """Remove connections that are not known by service driver."""
+        service_count = 0
+        connection_count = 0
+        for vpn_service_id, vpn_service in self.service_state.items():
+            dirty = [c_id for c_id, c in vpn_service.conn_state.items()
+                     if c.is_dirty]
+            for conn_id in dirty:
+                conn_state = vpn_service.conn_state[conn_id]
+                conn_state.delete_ipsec_site_connection(context, conn_id)
+                connection_count += 1
+                del vpn_service.conn_state[conn_id]
+            if vpn_service.is_dirty:
+                service_count += 1
+                del self.service_state[vpn_service_id]
+        LOG.debug(_("Sweep: Removed %(service)d dirty VPN service%(splural)s "
+                    "and %(conn)d dirty IPSec connection%(cplural)s"),
+                  {'service': service_count, 'conn': connection_count,
+                   'splural': 's'[service_count == 1:],
+                   'cplural': 's'[connection_count == 1:]})
+
+    def build_report_for_connections_on(self, vpn_service):
+        """Create the report fragment for IPSec connections on a service.
+
+        Collect the current status from the Cisco CSR and use that to update
+        the status and generate report fragment for each connection on the
+        service. If there is no status information, or no change, then no
+        report info will be created for the connection. The combined report
+        data is returned.
+        """
+        LOG.debug(_("Report: Collecting status for IPSec connections on VPN "
+                    "service %s"), vpn_service.service_id)
+        tunnels = vpn_service.get_ipsec_connections_status()
+        report = {}
+        for connection in vpn_service.conn_state.values():
+            current_status = connection.find_current_status_in(tunnels)
+            frag = connection.build_report_based_on_status(current_status)
+            if frag:
+                LOG.debug(_("Report: Adding info for IPSec connection %s"),
+                          connection.conn_id)
+                report.update(frag)
+        return report
+
+    def build_report_for_service(self, vpn_service):
+        """Create the report info for a VPN service and its IPSec connections.
+
+        Get the report info for the connections on the service, and include
+        it into the report info for the VPN service. If there is no report
+        info for the connection, then no change has occurred and no report
+        will be generated. If there is only one connection for the service,
+        we'll set the service state to match the connection (with ERROR seen
+        as DOWN).
+        """
+        conn_report = self.build_report_for_connections_on(vpn_service)
+        if conn_report:
+            pending_handled = plugin_utils.in_pending_status(
+                vpn_service.last_status)
+            if (len(conn_report) == 1 and
+                conn_report.values()[0]['status'] != constants.ACTIVE):
+                vpn_service.last_status = constants.DOWN
+            else:
+                vpn_service.last_status = constants.ACTIVE
+            LOG.debug(_("Report: Adding info for VPN service %s"),
+                      vpn_service.service_id)
+            return {u'id': vpn_service.service_id,
+                    u'status': vpn_service.last_status,
+                    u'updated_pending_status': pending_handled,
+                    u'ipsec_site_connections': conn_report}
+        else:
+            return {}
+
+    def report_status(self, context):
+        """Report status of all VPN services and IPSec connections to plugin.
+
+        This is called periodically by the agent, to push up status changes,
+        and at the end of any sync operation to reflect the changes due to a
+        sync or change notification.
+        """
+        service_report = []
+        LOG.debug(_("Report: Starting status report"))
+        for vpn_service_id, vpn_service in self.service_state.items():
+            LOG.debug(_("Report: Collecting status for VPN service %s"),
+                      vpn_service_id)
+            report = self.build_report_for_service(vpn_service)
+            if report:
+                service_report.append(report)
+        if service_report:
+            LOG.info(_("Sending status report update to plugin"))
+            self.agent_rpc.update_status(context, service_report)
+        LOG.debug(_("Report: Completed status report processing"))
+        return service_report
+
+    @lockutils.synchronized('vpn-agent', 'neutron-')
+    def sync(self, context, routers):
+        """Synchronize with plugin and report current status.
+
+        Mark all "known" services/connections as dirty, update them based on
+        information from the plugin, remove (sweep) any connections that are
+        not updated (dirty), and report updates, if any, back to plugin.
+        Called when update/delete a service or create/update/delete a
+        connection (vpnservice_updated message), or router change
+        (_process_routers).
+        """
+        self.mark_existing_connections_as_dirty()
+        self.update_all_services_and_connections(context)
+        self.remove_unknown_connections(context)
+        self.report_status(context)
+
+    def create_router(self, process_id):
+        """Actions taken when router created."""
+        # Note: Since Cisco CSR is running out-of-band, nothing to do here
+        pass
+
+    def destroy_router(self, process_id):
+        """Actions taken when router deleted."""
+        # Note: Since Cisco CSR is running out-of-band, nothing to do here
+        pass
+
+
+class CiscoCsrVpnService(object):
+
+    """Maintains state/status information for a service and its connections."""
+
+    def __init__(self, service_data, csr):
+        self.service_id = service_data['id']
+        self.last_status = service_data['status']
+        self.conn_state = {}
+        self.is_dirty = False
+        self.csr = csr
+        # TODO(pcm) FUTURE - handle sharing of policies
+
+    def create_connection(self, conn_data):
+        conn_id = conn_data['id']
+        self.conn_state[conn_id] = CiscoCsrIPSecConnection(conn_data, self.csr)
+        return self.conn_state[conn_id]
+
+    def get_connection(self, conn_id):
+        return self.conn_state.get(conn_id)
+
+    def conn_status(self, conn_id):
+        conn_state = self.get_connection(conn_id)
+        if conn_state:
+            return conn_state.last_status
+
+    def snapshot_conn_state(self, ipsec_conn):
+        """Create/obtain connection state and save current status."""
+        conn_state = self.conn_state.setdefault(
+            ipsec_conn['id'], CiscoCsrIPSecConnection(ipsec_conn, self.csr))
+        conn_state.last_status = ipsec_conn['status']
+        conn_state.is_dirty = False
+        return conn_state
+
+    STATUS_MAP = {'ERROR': constants.ERROR,
+                  'UP-ACTIVE': constants.ACTIVE,
+                  'UP-IDLE': constants.ACTIVE,
+                  'UP-NO-IKE': constants.ACTIVE,
+                  'DOWN': constants.DOWN,
+                  'DOWN-NEGOTIATING': constants.DOWN}
+
+    def get_ipsec_connections_status(self):
+        """Obtain current status of all tunnels on a Cisco CSR.
+
+        Convert them to OpenStack status values.
+        """
+        tunnels = self.csr.read_tunnel_statuses()
+        for tunnel in tunnels:
+            LOG.debug("CSR Reports %(tunnel)s status '%(status)s'",
+                      {'tunnel': tunnel[0], 'status': tunnel[1]})
+        return dict(map(lambda x: (x[0], self.STATUS_MAP[x[1]]), tunnels))
+
+    def find_matching_connection(self, tunnel_id):
+        """Find IPSec connection using Cisco CSR tunnel specified, if any."""
+        for connection in self.conn_state.values():
+            if connection.tunnel == tunnel_id:
+                return connection.conn_id
+
+
+class CiscoCsrIPSecConnection(object):
+
+    """State and actions for IPSec site-to-site connections."""
+
+    def __init__(self, conn_info, csr):
+        self.conn_id = conn_info['id']
+        self.csr = csr
+        self.steps = []
+        self.is_dirty = False
+        self.last_status = conn_info['status']
+        self.tunnel = conn_info['cisco']['site_conn_id']
+
+    def find_current_status_in(self, statuses):
+        if self.tunnel in statuses:
+            return statuses[self.tunnel]
+        else:
+            return constants.ERROR
+
+    def build_report_based_on_status(self, current_status):
+        if current_status != self.last_status:
+            pending_handled = plugin_utils.in_pending_status(self.last_status)
+            self.last_status = current_status
+            return {self.conn_id: {'status': current_status,
+                                   'updated_pending_status': pending_handled}}
+        else:
+            return {}
+
+    DIALECT_MAP = {'ike_policy': {'name': 'IKE Policy',
+                                  'v1': u'v1',
+                                  # auth_algorithm -> hash
+                                  'sha1': u'sha',
+                                  # encryption_algorithm -> encryption
+                                  '3des': u'3des',
+                                  'aes-128': u'aes',
+                                  # TODO(pcm) update these 2 once CSR updated
+                                  'aes-192': u'aes',
+                                  'aes-256': u'aes',
+                                  # pfs -> dhGroup
+                                  'group2': 2,
+                                  'group5': 5,
+                                  'group14': 14},
+                   'ipsec_policy': {'name': 'IPSec Policy',
+                                    # auth_algorithm -> esp-authentication
+                                    'sha1': u'esp-sha-hmac',
+                                    # transform_protocol -> ah
+                                    'esp': None,
+                                    'ah': u'ah-sha-hmac',
+                                    'ah-esp': u'ah-sha-hmac',
+                                    # encryption_algorithm -> esp-encryption
+                                    '3des': u'esp-3des',
+                                    'aes-128': u'esp-aes',
+                                    # TODO(pcm) update these 2 once CSR updated
+                                    'aes-192': u'esp-aes',
+                                    'aes-256': u'esp-aes',
+                                    # pfs -> pfs
+                                    'group2': u'group2',
+                                    'group5': u'group5',
+                                    'group14': u'group14'}}
+
+    def translate_dialect(self, resource, attribute, info):
+        """Map VPNaaS attributes values to CSR values for a resource."""
+        name = self.DIALECT_MAP[resource]['name']
+        if attribute not in info:
+            raise CsrDriverMismatchError(resource=name, attr=attribute)
+        value = info[attribute].lower()
+        if value in self.DIALECT_MAP[resource]:
+            return self.DIALECT_MAP[resource][value]
+        raise CsrUnknownMappingError(resource=name, attr=attribute,
+                                     value=value)
+
+    def create_psk_info(self, psk_id, conn_info):
+        """Collect/create attributes needed for pre-shared key."""
+        return {u'keyring-name': psk_id,
+                u'pre-shared-key-list': [
+                    {u'key': conn_info['psk'],
+                     u'encrypted': False,
+                     u'peer-address': conn_info['peer_address']}]}
+
+    def create_ike_policy_info(self, ike_policy_id, conn_info):
+        """Collect/create/map attributes needed for IKE policy."""
+        for_ike = 'ike_policy'
+        policy_info = conn_info[for_ike]
+        version = self.translate_dialect(for_ike,
+                                         'ike_version',
+                                         policy_info)
+        encrypt_algorithm = self.translate_dialect(for_ike,
+                                                   'encryption_algorithm',
+                                                   policy_info)
+        auth_algorithm = self.translate_dialect(for_ike,
+                                                'auth_algorithm',
+                                                policy_info)
+        group = self.translate_dialect(for_ike,
+                                       'pfs',
+                                       policy_info)
+        lifetime = policy_info['lifetime_value']
+        return {u'version': version,
+                u'priority-id': ike_policy_id,
+                u'encryption': encrypt_algorithm,
+                u'hash': auth_algorithm,
+                u'dhGroup': group,
+                u'lifetime': lifetime}
+
+    def create_ipsec_policy_info(self, ipsec_policy_id, info):
+        """Collect/create attributes needed for IPSec policy.
+
+        Note: OpenStack will provide a default encryption algorithm, if one is
+        not provided, so a authentication only configuration of (ah, sha1),
+        which maps to ah-sha-hmac transform protocol, cannot be selected.
+        As a result, we'll always configure the encryption algorithm, and
+        will select ah-sha-hmac for transform protocol.
+        """
+
+        for_ipsec = 'ipsec_policy'
+        policy_info = info[for_ipsec]
+        transform_protocol = self.translate_dialect(for_ipsec,
+                                                    'transform_protocol',
+                                                    policy_info)
+        auth_algorithm = self.translate_dialect(for_ipsec,
+                                                'auth_algorithm',
+                                                policy_info)
+        encrypt_algorithm = self.translate_dialect(for_ipsec,
+                                                   'encryption_algorithm',
+                                                   policy_info)
+        group = self.translate_dialect(for_ipsec, 'pfs', policy_info)
+        lifetime = policy_info['lifetime_value']
+        settings = {u'policy-id': ipsec_policy_id,
+                    u'protection-suite': {
+                        u'esp-encryption': encrypt_algorithm,
+                        u'esp-authentication': auth_algorithm},
+                    u'lifetime-sec': lifetime,
+                    u'pfs': group,
+                    # TODO(pcm): Remove when CSR fixes 'Disable'
+                    u'anti-replay-window-size': u'64'}
+        if transform_protocol:
+            settings[u'protection-suite'][u'ah'] = transform_protocol
+        return settings
+
+    def create_site_connection_info(self, site_conn_id, ipsec_policy_id,
+                                    conn_info):
+        """Collect/create attributes needed for the IPSec connection."""
+        # TODO(pcm) Enable, once CSR is embedded as a Neutron router
+        # gw_ip = vpnservice['external_ip'] (need to pass in)
+        mtu = conn_info['mtu']
+        return {
+            u'vpn-interface-name': site_conn_id,
+            u'ipsec-policy-id': ipsec_policy_id,
+            u'local-device': {
+                # TODO(pcm): FUTURE - Get CSR port of interface with
+                # local subnet
+                u'ip-address': u'GigabitEthernet3',
+                # TODO(pcm): FUTURE - Get IP address of router's public
+                # I/F, once CSR is used as embedded router.
+                u'tunnel-ip-address': u'172.24.4.23'
+                # u'tunnel-ip-address': u'%s' % gw_ip
+            },
+            u'remote-device': {
+                u'tunnel-ip-address': conn_info['peer_address']
+            },
+            u'mtu': mtu
+        }
+
+    def create_routes_info(self, site_conn_id, conn_info):
+        """Collect/create attributes for static routes."""
+        routes_info = []
+        for peer_cidr in conn_info.get('peer_cidrs', []):
+            route = {u'destination-network': peer_cidr,
+                     u'outgoing-interface': site_conn_id}
+            route_id = csr_client.make_route_id(peer_cidr, site_conn_id)
+            routes_info.append((route_id, route))
+        return routes_info
+
+    def _check_create(self, resource, which):
+        """Determine if REST create request was successful."""
+        if self.csr.status == httplib.CREATED:
+            LOG.debug("%(resource)s %(which)s is configured",
+                      {'resource': resource, 'which': which})
+            return
+        LOG.error(_("Unable to create %(resource)s %(which)s: "
+                    "%(status)d"),
+                  {'resource': resource, 'which': which,
+                   'status': self.csr.status})
+        # ToDO(pcm): Set state to error
+        raise CsrResourceCreateFailure(resource=resource, which=which)
+
+    def do_create_action(self, action_suffix, info, resource_id, title):
+        """Perform a single REST step for IPSec site connection create."""
+        create_action = 'create_%s' % action_suffix
+        try:
+            getattr(self.csr, create_action)(info)
+        except AttributeError:
+            LOG.exception(_("Internal error - '%s' is not defined"),
+                          create_action)
+            raise CsrResourceCreateFailure(resource=title,
+                                           which=resource_id)
+        self._check_create(title, resource_id)
+        self.steps.append(RollbackStep(action_suffix, resource_id, title))
+
+    def _verify_deleted(self, status, resource, which):
+        """Determine if REST delete request was successful."""
+        if status in (httplib.NO_CONTENT, httplib.NOT_FOUND):
+            LOG.debug("%(resource)s configuration %(which)s was removed",
+                      {'resource': resource, 'which': which})
+        else:
+            LOG.warning(_("Unable to delete %(resource)s %(which)s: "
+                          "%(status)d"), {'resource': resource,
+                                          'which': which,
+                                          'status': status})
+
+    def do_rollback(self):
+        """Undo create steps that were completed successfully."""
+        for step in reversed(self.steps):
+            delete_action = 'delete_%s' % step.action
+            LOG.debug(_("Performing rollback action %(action)s for "
+                        "resource %(resource)s"), {'action': delete_action,
+                                                   'resource': step.title})
+            try:
+                getattr(self.csr, delete_action)(step.resource_id)
+            except AttributeError:
+                LOG.exception(_("Internal error - '%s' is not defined"),
+                              delete_action)
+                raise CsrResourceCreateFailure(resource=step.title,
+                                               which=step.resource_id)
+            self._verify_deleted(self.csr.status, step.title, step.resource_id)
+        self.steps = []
+
+    def create_ipsec_site_connection(self, context, conn_info):
+        """Creates an IPSec site-to-site connection on CSR.
+
+        Create the PSK, IKE policy, IPSec policy, connection, static route,
+        and (future) DPD.
+        """
+        # Get all the IDs
+        conn_id = conn_info['id']
+        psk_id = conn_id
+        site_conn_id = conn_info['cisco']['site_conn_id']
+        ike_policy_id = conn_info['cisco']['ike_policy_id']
+        ipsec_policy_id = conn_info['cisco']['ipsec_policy_id']
+
+        LOG.debug(_('Creating IPSec connection %s'), conn_id)
+        # Get all the attributes needed to create
+        try:
+            psk_info = self.create_psk_info(psk_id, conn_info)
+            ike_policy_info = self.create_ike_policy_info(ike_policy_id,
+                                                          conn_info)
+            ipsec_policy_info = self.create_ipsec_policy_info(ipsec_policy_id,
+                                                              conn_info)
+            connection_info = self.create_site_connection_info(site_conn_id,
+                                                               ipsec_policy_id,
+                                                               conn_info)
+            routes_info = self.create_routes_info(site_conn_id, conn_info)
+        except (CsrUnknownMappingError, CsrDriverMismatchError) as e:
+            LOG.exception(e)
+            return
+
+        try:
+            self.do_create_action('pre_shared_key', psk_info,
+                                  conn_id, 'Pre-Shared Key')
+            self.do_create_action('ike_policy', ike_policy_info,
+                                  ike_policy_id, 'IKE Policy')
+            self.do_create_action('ipsec_policy', ipsec_policy_info,
+                                  ipsec_policy_id, 'IPSec Policy')
+            self.do_create_action('ipsec_connection', connection_info,
+                                  site_conn_id, 'IPSec Connection')
+
+            # TODO(pcm): FUTURE - Do DPD for v1 and handle if >1 connection
+            # and different DPD settings
+            for route_id, route_info in routes_info:
+                self.do_create_action('static_route', route_info,
+                                      route_id, 'Static Route')
+        except CsrResourceCreateFailure:
+            self.do_rollback()
+            LOG.info(_("FAILED: Create of IPSec site-to-site connection %s"),
+                     conn_id)
+        else:
+            LOG.info(_("SUCCESS: Created IPSec site-to-site connection %s"),
+                     conn_id)
+
+    def delete_ipsec_site_connection(self, context, conn_id):
+        """Delete the site-to-site IPSec connection.
+
+        This will be best effort and will continue, if there are any
+        failures.
+        """
+        LOG.debug(_('Deleting IPSec connection %s'), conn_id)
+        if not self.steps:
+            LOG.warning(_('Unable to find connection %s'), conn_id)
+        else:
+            self.do_rollback()
+
+        LOG.info(_("SUCCESS: Deleted IPSec site-to-site connection %s"),
+                 conn_id)
index 3df2fb036b041f56602564efb080ff17f5aae07c..e1f0760cd642b6b9a2f29ff2620ef00874ed8b64 100644 (file)
@@ -11,6 +11,8 @@
 #    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 #    License for the specific language governing permissions and limitations
 #    under the License.
+#
+# @author: Paul Michali, Cisco Systems, Inc.
 
 import sqlalchemy as sa
 from sqlalchemy.orm import exc as sql_exc
@@ -105,8 +107,9 @@ def get_next_available_ipsec_policy_id(session):
 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()
+    match = qry.filter_request(
+        policy_field == policy_id,
+        vpn_db.IPsecSiteConnection.id != conn_id).first()
     if match:
         return match[0]
 
@@ -215,6 +218,7 @@ def create_tunnel_mapping(context, conn_info):
                                   csr_ipsec_policy_id=csr_ipsec_id)
         try:
             context.session.add(map_entry)
+            # Force committing to database
             context.session.flush()
         except db_exc.DBDuplicateEntry:
             msg = _("Attempt to create duplicate entry in Cisco CSR "
diff --git a/neutron/tests/unit/services/vpn/device_drivers/cisco_csr_mock.py b/neutron/tests/unit/services/vpn/device_drivers/cisco_csr_mock.py
new file mode 100644 (file)
index 0000000..3830303
--- /dev/null
@@ -0,0 +1,538 @@
+# 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.
+#
+# @author: Paul Michali, Cisco Systems, Inc.
+
+"""Mock REST requests to Cisco Cloud Services Router."""
+
+import re
+
+from functools import wraps
+# import httmock
+import requests
+from requests import exceptions as r_exc
+
+from neutron.openstack.common import log as logging
+# TODO(pcm) Remove once httmock package is added to test-requirements. For
+# now, uncomment and include httmock source to UT
+from neutron.tests.unit.services.vpn.device_drivers import httmock
+
+# TODO(pcm) Remove, once verified these have been fixed
+FIXED_CSCum50512 = False
+FIXED_CSCum35484 = False
+FIXED_CSCul82396 = False
+FIXED_CSCum10324 = False
+
+LOG = logging.getLogger(__name__)
+
+
+def repeat(n):
+    """Decorator to limit the number of times a handler is called.
+
+    Will allow the wrapped function (handler) to be called 'n' times.
+    After that, this will return None for any additional calls,
+    allowing other handlers, if any, to be invoked.
+    """
+
+    class static:
+        retries = n
+
+    def decorator(func):
+        @wraps(func)
+        def wrapped(*args, **kwargs):
+            if static.retries == 0:
+                return None
+            static.retries -= 1
+            return func(*args, **kwargs)
+        return wrapped
+    return decorator
+
+
+def filter_request(methods, resource):
+    """Decorator to invoke handler once for a specific resource.
+
+    This will call the handler only for a specific resource using
+    a specific method(s). Any other resource request or method will
+    return None, allowing other handlers, if any, to be invoked.
+    """
+
+    class static:
+        target_methods = [m.upper() for m in methods]
+        target_resource = resource
+
+    def decorator(func):
+        @wraps(func)
+        def wrapped(*args, **kwargs):
+            if (args[1].method in static.target_methods and
+                static.target_resource in args[0].path):
+                return func(*args, **kwargs)
+            else:
+                return None  # Not for this resource
+        return wrapped
+    return decorator
+
+
+@httmock.urlmatch(netloc=r'localhost')
+def token(url, request):
+    if 'auth/token-services' in url.path:
+        return {'status_code': requests.codes.OK,
+                'content': {'token-id': 'dummy-token'}}
+
+
+@httmock.urlmatch(netloc=r'localhost')
+def token_unauthorized(url, request):
+    if 'auth/token-services' in url.path:
+        return {'status_code': requests.codes.UNAUTHORIZED}
+
+
+@httmock.urlmatch(netloc=r'wrong-host')
+def token_wrong_host(url, request):
+    raise r_exc.ConnectionError()
+
+
+@httmock.all_requests
+def token_timeout(url, request):
+    raise r_exc.Timeout()
+
+
+@filter_request(['get'], 'global/host-name')
+@httmock.all_requests
+def timeout(url, request):
+    """Simulated timeout of a normal request."""
+
+    if not request.headers.get('X-auth-token', None):
+        return {'status_code': requests.codes.UNAUTHORIZED}
+    raise r_exc.Timeout()
+
+
+@httmock.urlmatch(netloc=r'localhost')
+def no_such_resource(url, request):
+    """Indicate not found error, when invalid resource requested."""
+    return {'status_code': requests.codes.NOT_FOUND}
+
+
+@filter_request(['get'], 'global/host-name')
+@repeat(1)
+@httmock.urlmatch(netloc=r'localhost')
+def expired_request(url, request):
+    """Simulate access denied failure on first request for this resource.
+
+    Intent here is to simulate that the token has expired, by failing
+    the first request to the resource. Because of the repeat=1, this
+    will only be called once, and subsequent calls will not be handled
+    by this function, but instead will access the normal handler and
+    will pass. Currently configured for a GET request, but will work
+    with POST and PUT as well. For DELETE, would need to filter_request on a
+    different resource (e.g. 'global/local-users')
+    """
+
+    return {'status_code': requests.codes.UNAUTHORIZED}
+
+
+@httmock.urlmatch(netloc=r'localhost')
+def normal_get(url, request):
+    if request.method != 'GET':
+        return
+    LOG.debug("DEBUG: GET mock for %s", url)
+    if not request.headers.get('X-auth-token', None):
+        return {'status_code': requests.codes.UNAUTHORIZED}
+    if 'global/host-name' in url.path:
+        content = {u'kind': u'object#host-name',
+                   u'host-name': u'Router'}
+        return httmock.response(requests.codes.OK, content=content)
+    if 'global/local-users' in url.path:
+        content = {u'kind': u'collection#local-user',
+                   u'users': ['peter', 'paul', 'mary']}
+        return httmock.response(requests.codes.OK, content=content)
+    if 'interfaces/GigabitEthernet' in url.path:
+        actual_interface = url.path.split('/')[-1]
+        ip = actual_interface[-1]
+        content = {u'kind': u'object#interface',
+                   u'description': u'Changed description',
+                   u'if-name': actual_interface,
+                   u'proxy-arp': True,
+                   u'subnet-mask': u'255.255.255.0',
+                   u'icmp-unreachable': True,
+                   u'nat-direction': u'',
+                   u'icmp-redirects': True,
+                   u'ip-address': u'192.168.200.%s' % ip,
+                   u'verify-unicast-source': False,
+                   u'type': u'ethernet'}
+        return httmock.response(requests.codes.OK, content=content)
+    if 'vpn-svc/ike/policies/2' in url.path:
+        content = {u'kind': u'object#ike-policy',
+                   u'priority-id': u'2',
+                   u'version': u'v1',
+                   u'local-auth-method': u'pre-share',
+                   u'encryption': u'aes',
+                   u'hash': u'sha',
+                   u'dhGroup': 5,
+                   u'lifetime': 3600}
+        return httmock.response(requests.codes.OK, content=content)
+    if 'vpn-svc/ike/keyrings' in url.path:
+        content = {u'kind': u'object#ike-keyring',
+                   u'keyring-name': u'5',
+                   u'pre-shared-key-list': [
+                       {u'key': u'super-secret',
+                        u'encrypted': False,
+                        u'peer-address': u'10.10.10.20 255.255.255.0'}
+                   ]}
+        return httmock.response(requests.codes.OK, content=content)
+    if 'vpn-svc/ipsec/policies/' in url.path:
+        ipsec_policy_id = url.path.split('/')[-1]
+        content = {u'kind': u'object#ipsec-policy',
+                   u'mode': u'tunnel',
+                   u'policy-id': u'%s' % ipsec_policy_id,
+                   u'protection-suite': {
+                       u'esp-encryption': u'esp-aes',
+                       u'esp-authentication': u'esp-sha-hmac',
+                       u'ah': u'ah-sha-hmac',
+                   },
+                   u'anti-replay-window-size': u'128',
+                   u'lifetime-sec': 120,
+                   u'pfs': u'group5',
+                   u'lifetime-kb': 4608000,
+                   u'idle-time': None}
+        return httmock.response(requests.codes.OK, content=content)
+    if 'vpn-svc/site-to-site/Tunnel' in url.path:
+        tunnel = url.path.split('/')[-1]
+        # Use same number, to allow mock to generate IPSec policy ID
+        ipsec_policy_id = tunnel[6:]
+        content = {u'kind': u'object#vpn-site-to-site',
+                   u'vpn-interface-name': u'%s' % tunnel,
+                   u'ip-version': u'ipv4',
+                   u'vpn-type': u'site-to-site',
+                   u'ipsec-policy-id': u'%s' % ipsec_policy_id,
+                   u'ike-profile-id': None,
+                   u'mtu': 1500,
+                   u'local-device': {
+                       u'ip-address': '10.3.0.1/24',
+                       u'tunnel-ip-address': '10.10.10.10'
+                   },
+                   u'remote-device': {
+                       u'tunnel-ip-address': '10.10.10.20'
+                   }}
+        return httmock.response(requests.codes.OK, content=content)
+    if 'vpn-svc/ike/keepalive' in url.path:
+        content = {u'interval': 60,
+                   u'retry': 4,
+                   u'periodic': True}
+        return httmock.response(requests.codes.OK, content=content)
+    if 'routing-svc/static-routes' in url.path:
+        content = {u'destination-network': u'10.1.0.0/24',
+                   u'kind': u'object#static-route',
+                   u'next-hop-router': None,
+                   u'outgoing-interface': u'GigabitEthernet1',
+                   u'admin-distance': 1}
+        return httmock.response(requests.codes.OK, content=content)
+    if 'vpn-svc/site-to-site/active/sessions':
+        # Only including needed fields for mock
+        content = {u'kind': u'collection#vpn-active-sessions',
+                   u'items': [{u'status': u'DOWN-NEGOTIATING',
+                               u'vpn-interface-name': u'Tunnel123'}, ]}
+        return httmock.response(requests.codes.OK, content=content)
+
+
+@filter_request(['get'], 'vpn-svc/ike/keyrings')
+@httmock.urlmatch(netloc=r'localhost')
+def get_fqdn(url, request):
+    LOG.debug("DEBUG: GET FQDN mock for %s", url)
+    if not request.headers.get('X-auth-token', None):
+        return {'status_code': requests.codes.UNAUTHORIZED}
+    content = {u'kind': u'object#ike-keyring',
+               u'keyring-name': u'5',
+               u'pre-shared-key-list': [
+                   {u'key': u'super-secret',
+                    u'encrypted': False,
+                    u'peer-address': u'cisco.com'}
+               ]}
+    return httmock.response(requests.codes.OK, content=content)
+
+
+@filter_request(['get'], 'vpn-svc/ipsec/policies/')
+@httmock.urlmatch(netloc=r'localhost')
+def get_no_ah(url, request):
+    LOG.debug("DEBUG: GET No AH mock for %s", url)
+    if not request.headers.get('X-auth-token', None):
+        return {'status_code': requests.codes.UNAUTHORIZED}
+    ipsec_policy_id = url.path.split('/')[-1]
+    content = {u'kind': u'object#ipsec-policy',
+               u'mode': u'tunnel',
+               u'anti-replay-window-size': u'128',
+               u'policy-id': u'%s' % ipsec_policy_id,
+               u'protection-suite': {
+                   u'esp-encryption': u'esp-aes',
+                   u'esp-authentication': u'esp-sha-hmac',
+               },
+               u'lifetime-sec': 120,
+               u'pfs': u'group5',
+               u'lifetime-kb': 4608000,
+               u'idle-time': None}
+    return httmock.response(requests.codes.OK, content=content)
+
+
+@httmock.urlmatch(netloc=r'localhost')
+def get_defaults(url, request):
+    if request.method != 'GET':
+        return
+    LOG.debug("DEBUG: GET mock for %s", url)
+    if not request.headers.get('X-auth-token', None):
+        return {'status_code': requests.codes.UNAUTHORIZED}
+    if 'vpn-svc/ike/policies/2' in url.path:
+        content = {u'kind': u'object#ike-policy',
+                   u'priority-id': u'2',
+                   u'version': u'v1',
+                   u'local-auth-method': u'pre-share',
+                   u'encryption': u'des',
+                   u'hash': u'sha',
+                   u'dhGroup': 1,
+                   u'lifetime': 86400}
+        return httmock.response(requests.codes.OK, content=content)
+    if 'vpn-svc/ipsec/policies/' in url.path:
+        ipsec_policy_id = url.path.split('/')[-1]
+        content = {u'kind': u'object#ipsec-policy',
+                   u'mode': u'tunnel',
+                   u'policy-id': u'%s' % ipsec_policy_id,
+                   u'protection-suite': {},
+                   u'lifetime-sec': 3600,
+                   u'pfs': u'Disable',
+                   u'anti-replay-window-size': u'None',
+                   u'lifetime-kb': 4608000,
+                   u'idle-time': None}
+        return httmock.response(requests.codes.OK, content=content)
+
+
+@filter_request(['get'], 'vpn-svc/site-to-site')
+@httmock.urlmatch(netloc=r'localhost')
+def get_unnumbered(url, request):
+    if not request.headers.get('X-auth-token', None):
+        return {'status_code': requests.codes.UNAUTHORIZED}
+    if FIXED_CSCum50512:
+        tunnel = url.path.split('/')[-1]
+        ipsec_policy_id = tunnel[6:]
+        content = {u'kind': u'object#vpn-site-to-site',
+                   u'vpn-interface-name': u'%s' % tunnel,
+                   u'ip-version': u'ipv4',
+                   u'vpn-type': u'site-to-site',
+                   u'ipsec-policy-id': u'%s' % ipsec_policy_id,
+                   u'ike-profile-id': None,
+                   u'mtu': 1500,
+                   u'local-device': {
+                       u'ip-address': u'unnumbered GigabitEthernet3',
+                       u'tunnel-ip-address': u'10.10.10.10'
+                   },
+                   u'remote-device': {
+                       u'tunnel-ip-address': u'10.10.10.20'
+                   }}
+        return httmock.response(requests.codes.OK, content=content)
+    else:
+        return httmock.response(requests.codes.INTERNAL_SERVER_ERROR)
+
+
+@filter_request(['get'], 'vpn-svc/site-to-site')
+@httmock.urlmatch(netloc=r'localhost')
+def get_mtu(url, request):
+    if not request.headers.get('X-auth-token', None):
+        return {'status_code': requests.codes.UNAUTHORIZED}
+    tunnel = url.path.split('/')[-1]
+    ipsec_policy_id = tunnel[6:]
+    content = {u'kind': u'object#vpn-site-to-site',
+               u'vpn-interface-name': u'%s' % tunnel,
+               u'ip-version': u'ipv4',
+               u'vpn-type': u'site-to-site',
+               u'ipsec-policy-id': u'%s' % ipsec_policy_id,
+               u'ike-profile-id': None,
+               u'mtu': 9192,
+               u'local-device': {
+                   u'ip-address': u'10.3.0.1/24',
+                   u'tunnel-ip-address': u'10.10.10.10'
+               },
+               u'remote-device': {
+                   u'tunnel-ip-address': u'10.10.10.20'
+               }}
+    return httmock.response(requests.codes.OK, content=content)
+
+
+@filter_request(['get'], 'vpn-svc/ike/keepalive')
+@httmock.urlmatch(netloc=r'localhost')
+def get_not_configured(url, request):
+    if not request.headers.get('X-auth-token', None):
+        return {'status_code': requests.codes.UNAUTHORIZED}
+    return {'status_code': requests.codes.NOT_FOUND}
+
+
+@filter_request(['get'], 'vpn-svc/site-to-site/active/sessions')
+@httmock.urlmatch(netloc=r'localhost')
+def get_none(url, request):
+    if not request.headers.get('X-auth-token', None):
+        return {'status_code': requests.codes.UNAUTHORIZED}
+    content = {u'kind': u'collection#vpn-active-sessions',
+               u'items': []}
+    return httmock.response(requests.codes.OK, content=content)
+
+
+@httmock.urlmatch(netloc=r'localhost')
+def post(url, request):
+    if request.method != 'POST':
+        return
+    LOG.debug("DEBUG: POST mock for %s", url)
+    if not request.headers.get('X-auth-token', None):
+        return {'status_code': requests.codes.UNAUTHORIZED}
+    if 'interfaces/GigabitEthernet' in url.path:
+        return {'status_code': requests.codes.NO_CONTENT}
+    if 'global/local-users' in url.path:
+        if 'username' not in request.body:
+            return {'status_code': requests.codes.BAD_REQUEST}
+        if '"privilege": 20' in request.body:
+            return {'status_code': requests.codes.BAD_REQUEST}
+        headers = {'location': '%s/test-user' % url.geturl()}
+        return httmock.response(requests.codes.CREATED, headers=headers)
+    if 'vpn-svc/ike/policies' in url.path:
+        headers = {'location': "%s/2" % url.geturl()}
+        return httmock.response(requests.codes.CREATED, headers=headers)
+    if 'vpn-svc/ipsec/policies' in url.path:
+        m = re.search(r'"policy-id": "(\S+)"', request.body)
+        if m:
+            headers = {'location': "%s/%s" % (url.geturl(), m.group(1))}
+            return httmock.response(requests.codes.CREATED, headers=headers)
+        return {'status_code': requests.codes.BAD_REQUEST}
+    if 'vpn-svc/ike/keyrings' in url.path:
+        headers = {'location': "%s/5" % url.geturl()}
+        return httmock.response(requests.codes.CREATED, headers=headers)
+    if 'vpn-svc/site-to-site' in url.path:
+        m = re.search(r'"vpn-interface-name": "(\S+)"', request.body)
+        if m:
+            headers = {'location': "%s/%s" % (url.geturl(), m.group(1))}
+            return httmock.response(requests.codes.CREATED, headers=headers)
+        return {'status_code': requests.codes.BAD_REQUEST}
+    if 'routing-svc/static-routes' in url.path:
+        headers = {'location':
+                   "%s/10.1.0.0_24_GigabitEthernet1" % url.geturl()}
+        return httmock.response(requests.codes.CREATED, headers=headers)
+
+
+@filter_request(['post'], 'global/local-users')
+@httmock.urlmatch(netloc=r'localhost')
+def post_change_attempt(url, request):
+    LOG.debug("DEBUG: POST change value mock for %s", url)
+    if not request.headers.get('X-auth-token', None):
+        return {'status_code': requests.codes.UNAUTHORIZED}
+    return {'status_code': requests.codes.NOT_FOUND,
+            'content': {
+                u'error-code': -1,
+                u'error-message': u'user test-user already exists'}}
+
+
+@httmock.urlmatch(netloc=r'localhost')
+def post_duplicate(url, request):
+    LOG.debug("DEBUG: POST duplicate mock for %s", url)
+    if not request.headers.get('X-auth-token', None):
+        return {'status_code': requests.codes.UNAUTHORIZED}
+    return {'status_code': requests.codes.BAD_REQUEST,
+            'content': {
+                u'error-code': -1,
+                u'error-message': u'policy 2 exist, not allow to '
+                                  u'update policy using POST method'}}
+
+
+@filter_request(['post'], 'vpn-svc/site-to-site')
+@httmock.urlmatch(netloc=r'localhost')
+def post_missing_ipsec_policy(url, request):
+    LOG.debug("DEBUG: POST missing ipsec policy mock for %s", url)
+    if not request.headers.get('X-auth-token', None):
+        return {'status_code': requests.codes.UNAUTHORIZED}
+    return {'status_code': requests.codes.BAD_REQUEST}
+
+
+@filter_request(['post'], 'vpn-svc/site-to-site')
+@httmock.urlmatch(netloc=r'localhost')
+def post_missing_ike_policy(url, request):
+    LOG.debug("DEBUG: POST missing ike policy mock for %s", url)
+    if not request.headers.get('X-auth-token', None):
+        return {'status_code': requests.codes.UNAUTHORIZED}
+    return {'status_code': requests.codes.BAD_REQUEST}
+
+
+@filter_request(['post'], 'vpn-svc/site-to-site')
+@httmock.urlmatch(netloc=r'localhost')
+def post_bad_ip(url, request):
+    LOG.debug("DEBUG: POST bad IP mock for %s", url)
+    if not request.headers.get('X-auth-token', None):
+        return {'status_code': requests.codes.UNAUTHORIZED}
+    return {'status_code': requests.codes.BAD_REQUEST}
+
+
+@filter_request(['post'], 'vpn-svc/site-to-site')
+@httmock.urlmatch(netloc=r'localhost')
+def post_bad_mtu(url, request):
+    LOG.debug("DEBUG: POST bad mtu mock for %s", url)
+    if not request.headers.get('X-auth-token', None):
+        return {'status_code': requests.codes.UNAUTHORIZED}
+    return {'status_code': requests.codes.BAD_REQUEST}
+
+
+@filter_request(['post'], 'vpn-svc/ipsec/policies')
+@httmock.urlmatch(netloc=r'localhost')
+def post_bad_lifetime(url, request):
+    LOG.debug("DEBUG: POST bad lifetime mock for %s", url)
+    if not request.headers.get('X-auth-token', None):
+        return {'status_code': requests.codes.UNAUTHORIZED}
+    return {'status_code': requests.codes.BAD_REQUEST}
+
+
+@httmock.urlmatch(netloc=r'localhost')
+def put(url, request):
+    if request.method != 'PUT':
+        return
+    LOG.debug("DEBUG: PUT mock for %s", url)
+    if not request.headers.get('X-auth-token', None):
+        return {'status_code': requests.codes.UNAUTHORIZED}
+    # Any resource
+    return {'status_code': requests.codes.NO_CONTENT}
+
+
+@httmock.urlmatch(netloc=r'localhost')
+def delete(url, request):
+    if request.method != 'DELETE':
+        return
+    LOG.debug("DEBUG: DELETE mock for %s", url)
+    if not request.headers.get('X-auth-token', None):
+        return {'status_code': requests.codes.UNAUTHORIZED}
+    # Any resource
+    return {'status_code': requests.codes.NO_CONTENT}
+
+
+@httmock.urlmatch(netloc=r'localhost')
+def delete_unknown(url, request):
+    if request.method != 'DELETE':
+        return
+    LOG.debug("DEBUG: DELETE unknown mock for %s", url)
+    if not request.headers.get('X-auth-token', None):
+        return {'status_code': requests.codes.UNAUTHORIZED}
+    # Any resource
+    return {'status_code': requests.codes.NOT_FOUND,
+            'content': {
+                u'error-code': -1,
+                u'error-message': 'user unknown not found'}}
+
+
+@httmock.urlmatch(netloc=r'localhost')
+def delete_not_allowed(url, request):
+    if request.method != 'DELETE':
+        return
+    LOG.debug("DEBUG: DELETE not allowed mock for %s", url)
+    if not request.headers.get('X-auth-token', None):
+        return {'status_code': requests.codes.UNAUTHORIZED}
+    # Any resource
+    return {'status_code': requests.codes.METHOD_NOT_ALLOWED}
diff --git a/neutron/tests/unit/services/vpn/device_drivers/notest_cisco_csr_rest.py b/neutron/tests/unit/services/vpn/device_drivers/notest_cisco_csr_rest.py
new file mode 100644 (file)
index 0000000..920b9e4
--- /dev/null
@@ -0,0 +1,1206 @@
+# 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.
+#
+# @author: Paul Michali, Cisco Systems, Inc.
+
+#TODO(pcm): Rename this file to remove the "no" prefix, once httmock is
+# approved and added to requirements.txt
+
+import random
+
+import httmock
+import requests
+
+from neutron.openstack.common import log as logging
+from neutron.services.vpn.device_drivers import (
+    cisco_csr_rest_client as csr_client)
+from neutron.tests import base
+from neutron.tests.unit.services.vpn.device_drivers import (
+    cisco_csr_mock as csr_request)
+# TODO(pcm) Remove once httmock is available. In the meantime, use temp
+# copy of hhtmock source to run UT
+# from neutron.tests.unit.services.vpn.device_drivers import httmock
+
+
+LOG = logging.getLogger(__name__)
+# Enables debug logging to console
+if True:
+    logging.CONF.set_override('debug', True)
+    logging.setup('neutron')
+
+if csr_request.FIXED_CSCum35484:
+    dummy_uuid = '1eb4ee6b-0870-45a0-b554-7b69096'
+else:
+    dummy_uuid = '1eb4ee6b-0870-45a0-b554-7b'
+
+
+# Note: Helper functions to test reuse of IDs.
+def generate_pre_shared_key_id():
+    return random.randint(100, 200)
+
+
+def generate_ike_policy_id():
+    return random.randint(200, 300)
+
+
+def generate_ipsec_policy_id():
+    return random.randint(300, 400)
+
+
+class TestCsrLoginRestApi(base.BaseTestCase):
+
+    """Test logging into CSR to obtain token-id."""
+
+    def setUp(self):
+        super(TestCsrLoginRestApi, self).setUp()
+        self.csr = csr_client.CsrRestClient('localhost', '10.10.10.10',
+                                            'stack', 'cisco')
+
+    def test_get_token(self):
+        """Obtain the token and its expiration time."""
+        with httmock.HTTMock(csr_request.token):
+            self.assertTrue(self.csr.authenticate())
+            self.assertEqual(requests.codes.OK, self.csr.status)
+            self.assertIsNotNone(self.csr.token)
+
+    def test_unauthorized_token_request(self):
+        """Negative test of invalid user/password."""
+        self.csr.auth = ('stack', 'bogus')
+        with httmock.HTTMock(csr_request.token_unauthorized):
+            self.assertIsNone(self.csr.authenticate())
+            self.assertEqual(requests.codes.UNAUTHORIZED, self.csr.status)
+
+    def test_non_existent_host(self):
+        """Negative test of request to non-existent host."""
+        self.csr.host = 'wrong-host'
+        self.csr.token = 'Set by some previously successful access'
+        with httmock.HTTMock(csr_request.token_wrong_host):
+            self.assertIsNone(self.csr.authenticate())
+            self.assertEqual(requests.codes.NOT_FOUND, self.csr.status)
+            self.assertIsNone(self.csr.token)
+
+    def test_timeout_on_token_access(self):
+        """Negative test of a timeout on a request."""
+        with httmock.HTTMock(csr_request.token_timeout):
+            self.assertIsNone(self.csr.authenticate())
+            self.assertEqual(requests.codes.REQUEST_TIMEOUT, self.csr.status)
+            self.assertIsNone(self.csr.token)
+
+
+class TestCsrGetRestApi(base.BaseTestCase):
+
+    """Test CSR GET REST API."""
+
+    def setUp(self):
+        super(TestCsrGetRestApi, self).setUp()
+        self.csr = csr_client.CsrRestClient('localhost', '10.10.10.10',
+                                            'stack', 'cisco')
+
+    def test_valid_rest_gets(self):
+        """Simple GET requests.
+
+        First request will do a post to get token (login). Assumes
+        that there are two interfaces on the CSR.
+        """
+
+        with httmock.HTTMock(csr_request.token, csr_request.normal_get):
+            content = self.csr.get_request('global/host-name')
+            self.assertEqual(requests.codes.OK, self.csr.status)
+            self.assertIn('host-name', content)
+            self.assertNotEqual(None, content['host-name'])
+
+            content = self.csr.get_request('global/local-users')
+            self.assertEqual(requests.codes.OK, self.csr.status)
+            self.assertIn('users', content)
+
+
+class TestCsrPostRestApi(base.BaseTestCase):
+
+    """Test CSR POST REST API."""
+
+    def setUp(self):
+        super(TestCsrPostRestApi, self).setUp()
+        self.csr = csr_client.CsrRestClient('localhost', '10.10.10.10',
+                                            'stack', 'cisco')
+
+    def test_post_requests(self):
+        """Simple POST requests (repeatable).
+
+        First request will do a post to get token (login). Assumes
+        that there are two interfaces (Ge1 and Ge2) on the CSR.
+        """
+
+        with httmock.HTTMock(csr_request.token, csr_request.post):
+            content = self.csr.post_request(
+                'interfaces/GigabitEthernet1/statistics',
+                payload={'action': 'clear'})
+            self.assertEqual(requests.codes.NO_CONTENT, self.csr.status)
+            self.assertIsNone(content)
+            content = self.csr.post_request(
+                'interfaces/GigabitEthernet2/statistics',
+                payload={'action': 'clear'})
+            self.assertEqual(requests.codes.NO_CONTENT, self.csr.status)
+            self.assertIsNone(content)
+
+    def test_post_with_location(self):
+        """Create a user and verify that location returned."""
+        with httmock.HTTMock(csr_request.token, csr_request.post):
+            location = self.csr.post_request(
+                'global/local-users',
+                payload={'username': 'test-user',
+                         'password': 'pass12345',
+                         'privilege': 15})
+            self.assertEqual(requests.codes.CREATED, self.csr.status)
+            self.assertIn('global/local-users/test-user', location)
+
+    def test_post_missing_required_attribute(self):
+        """Negative test of POST with missing mandatory info."""
+        with httmock.HTTMock(csr_request.token, csr_request.post):
+            self.csr.post_request('global/local-users',
+                                  payload={'password': 'pass12345',
+                                           'privilege': 15})
+            self.assertEqual(requests.codes.BAD_REQUEST, self.csr.status)
+
+    def test_post_invalid_attribute(self):
+        """Negative test of POST with invalid info."""
+        with httmock.HTTMock(csr_request.token, csr_request.post):
+            self.csr.post_request('global/local-users',
+                                  payload={'username': 'test-user',
+                                           'password': 'pass12345',
+                                           'privilege': 20})
+            self.assertEqual(requests.codes.BAD_REQUEST, self.csr.status)
+
+    def test_post_already_exists(self):
+        """Negative test of a duplicate POST.
+
+        Uses the lower level _do_request() API to just perform the POST and
+        obtain the response, without any error processing.
+        """
+        with httmock.HTTMock(csr_request.token, csr_request.post):
+                location = self.csr._do_request(
+                    'POST',
+                    'global/local-users',
+                    payload={'username': 'test-user',
+                             'password': 'pass12345',
+                             'privilege': 15},
+                    more_headers=csr_client.HEADER_CONTENT_TYPE_JSON)
+                self.assertEqual(requests.codes.CREATED, self.csr.status)
+                self.assertIn('global/local-users/test-user', location)
+        with httmock.HTTMock(csr_request.token,
+                             csr_request.post_change_attempt):
+                self.csr._do_request(
+                    'POST',
+                    'global/local-users',
+                    payload={'username': 'test-user',
+                             'password': 'pass12345',
+                             'privilege': 15},
+                    more_headers=csr_client.HEADER_CONTENT_TYPE_JSON)
+                # Note: For local-user, a 404 error is returned. For
+                # site-to-site connection a 400 is returned.
+                self.assertEqual(requests.codes.NOT_FOUND, self.csr.status)
+
+    def test_post_changing_value(self):
+        """Negative test of a POST trying to change a value."""
+        with httmock.HTTMock(csr_request.token, csr_request.post):
+            location = self.csr.post_request(
+                'global/local-users',
+                payload={'username': 'test-user',
+                         'password': 'pass12345',
+                         'privilege': 15})
+            self.assertEqual(requests.codes.CREATED, self.csr.status)
+            self.assertIn('global/local-users/test-user', location)
+        with httmock.HTTMock(csr_request.token,
+                             csr_request.post_change_attempt):
+            content = self.csr.post_request('global/local-users',
+                                            payload={'username': 'test-user',
+                                                     'password': 'changed',
+                                                     'privilege': 15})
+            self.assertEqual(requests.codes.NOT_FOUND, self.csr.status)
+            expected = {u'error-code': -1,
+                        u'error-message': u'user test-user already exists'}
+            self.assertDictContainsSubset(expected, content)
+
+
+class TestCsrPutRestApi(base.BaseTestCase):
+
+    """Test CSR PUT REST API."""
+
+    def _save_resources(self):
+        with httmock.HTTMock(csr_request.token, csr_request.normal_get):
+            details = self.csr.get_request('global/host-name')
+            if self.csr.status != requests.codes.OK:
+                self.fail("Unable to save original host name")
+            self.original_host = details['host-name']
+            details = self.csr.get_request('interfaces/GigabitEthernet1')
+            if self.csr.status != requests.codes.OK:
+                self.fail("Unable to save interface Ge1 description")
+            self.original_if = details
+            if details.get('description', ''):
+                self.original_if['description'] = ''
+            self.csr.token = None
+
+    def _restore_resources(self, user, password):
+        """Restore the host name and itnerface description.
+
+        Must restore the user and password, so that authentication
+        token can be obtained (as some tests corrupt auth info).
+        Will also clear token, so that it gets a fresh token.
+        """
+
+        self.csr.auth = (user, password)
+        self.csr.token = None
+        with httmock.HTTMock(csr_request.token, csr_request.put):
+            payload = {'host-name': self.original_host}
+            self.csr.put_request('global/host-name', payload=payload)
+            if self.csr.status != requests.codes.NO_CONTENT:
+                self.fail("Unable to restore host name after test")
+            payload = {'description': self.original_if['description'],
+                       'if-name': self.original_if['if-name'],
+                       'ip-address': self.original_if['ip-address'],
+                       'subnet-mask': self.original_if['subnet-mask'],
+                       'type': self.original_if['type']}
+            self.csr.put_request('interfaces/GigabitEthernet1',
+                                 payload=payload)
+            if self.csr.status != requests.codes.NO_CONTENT:
+                self.fail("Unable to restore I/F Ge1 description after test")
+
+    def setUp(self):
+        """Prepare for PUT API tests."""
+        super(TestCsrPutRestApi, self).setUp()
+        self.csr = csr_client.CsrRestClient('localhost', '10.10.10.10',
+                                            'stack', 'cisco')
+        self._save_resources()
+        self.addCleanup(self._restore_resources, 'stack', 'cisco')
+
+    def test_put_requests(self):
+        """Simple PUT requests (repeatable).
+
+        First request will do a post to get token (login). Assumes
+        that there are two interfaces on the CSR (Ge1 and Ge2).
+        """
+
+        with httmock.HTTMock(csr_request.token, csr_request.put,
+                             csr_request.normal_get):
+            payload = {'host-name': 'TestHost'}
+            content = self.csr.put_request('global/host-name',
+                                           payload=payload)
+            self.assertEqual(requests.codes.NO_CONTENT, self.csr.status)
+            self.assertIsNone(content)
+
+            payload = {'host-name': 'TestHost2'}
+            content = self.csr.put_request('global/host-name',
+                                           payload=payload)
+            self.assertEqual(requests.codes.NO_CONTENT, self.csr.status)
+            self.assertIsNone(content)
+
+    def test_change_interface_description(self):
+        """Test that interface description can be changed.
+
+        This was a problem with an earlier version of the CSR image and is
+        here to prevent regression.
+        """
+        with httmock.HTTMock(csr_request.token, csr_request.put,
+                             csr_request.normal_get):
+            payload = {'description': u'Changed description',
+                       'if-name': self.original_if['if-name'],
+                       'ip-address': self.original_if['ip-address'],
+                       'subnet-mask': self.original_if['subnet-mask'],
+                       'type': self.original_if['type']}
+            content = self.csr.put_request(
+                'interfaces/GigabitEthernet1', payload=payload)
+            self.assertEqual(requests.codes.NO_CONTENT, self.csr.status)
+            self.assertIsNone(content)
+            content = self.csr.get_request('interfaces/GigabitEthernet1')
+            self.assertEqual(requests.codes.OK, self.csr.status)
+            self.assertIn('description', content)
+            self.assertEqual(u'Changed description',
+                             content['description'])
+
+    def ignore_test_change_to_empty_interface_description(self):
+        """Test that interface description can be changed to empty string.
+
+        This is a problem in the current version of the CSR image, which
+        rejects the change with a 400 error. This test is here to prevent
+        a regression (once it is fixed) Note that there is code in the
+        test setup to change the description to a non-empty string to
+        avoid failures in other tests.
+        """
+        with httmock.HTTMock(csr_request.token, csr_request.put,
+                             csr_request.normal_get):
+            payload = {'description': '',
+                       'if-name': self.original_if['if-name'],
+                       'ip-address': self.original_if['ip-address'],
+                       'subnet-mask': self.original_if['subnet-mask'],
+                       'type': self.original_if['type']}
+            content = self.csr.put_request(
+                'interfaces/GigabitEthernet1', payload=payload)
+            self.assertEqual(requests.codes.NO_CONTENT, self.csr.status)
+            self.assertIsNone(content)
+            content = self.csr.get_request('interfaces/GigabitEthernet1')
+            self.assertEqual(requests.codes.OK, self.csr.status)
+            self.assertIn('description', content)
+            self.assertEqual('', content['description'])
+
+
+class TestCsrDeleteRestApi(base.BaseTestCase):
+
+    """Test CSR DELETE REST API."""
+
+    def setUp(self):
+        super(TestCsrDeleteRestApi, self).setUp()
+        self.csr = csr_client.CsrRestClient('localhost', '10.10.10.10',
+                                            'stack', 'cisco')
+
+    def _make_dummy_user(self):
+        """Create a user that will be later deleted."""
+        self.csr.post_request('global/local-users',
+                              payload={'username': 'dummy',
+                                       'password': 'dummy',
+                                       'privilege': 15})
+        self.assertEqual(requests.codes.CREATED, self.csr.status)
+
+    def test_delete_requests(self):
+        """Simple DELETE requests (creating entry first)."""
+        with httmock.HTTMock(csr_request.token, csr_request.post,
+                             csr_request.delete):
+            self._make_dummy_user()
+            self.csr.token = None  # Force login
+            self.csr.delete_request('global/local-users/dummy')
+            self.assertEqual(requests.codes.NO_CONTENT, self.csr.status)
+            # Delete again, but without logging in this time
+            self._make_dummy_user()
+            self.csr.delete_request('global/local-users/dummy')
+            self.assertEqual(requests.codes.NO_CONTENT, self.csr.status)
+
+    def test_delete_non_existent_entry(self):
+        """Negative test of trying to delete a non-existent user."""
+        with httmock.HTTMock(csr_request.token, csr_request.delete_unknown):
+            content = self.csr.delete_request('global/local-users/unknown')
+            self.assertEqual(requests.codes.NOT_FOUND, self.csr.status)
+            expected = {u'error-code': -1,
+                        u'error-message': u'user unknown not found'}
+            self.assertDictContainsSubset(expected, content)
+
+    def test_delete_not_allowed(self):
+        """Negative test of trying to delete the host-name."""
+        with httmock.HTTMock(csr_request.token,
+                             csr_request.delete_not_allowed):
+            self.csr.delete_request('global/host-name')
+            self.assertEqual(requests.codes.METHOD_NOT_ALLOWED,
+                             self.csr.status)
+
+
+class TestCsrRestApiFailures(base.BaseTestCase):
+
+    """Test failure cases common for all REST APIs.
+
+    Uses the lower level _do_request() to just perform the operation and get
+    the result, without any error handling.
+    """
+
+    def setUp(self):
+        super(TestCsrRestApiFailures, self).setUp()
+        self.csr = csr_client.CsrRestClient('localhost', '10.10.10.10',
+                                            'stack', 'cisco', timeout=0.1)
+
+    def test_request_for_non_existent_resource(self):
+        """Negative test of non-existent resource on REST request."""
+        with httmock.HTTMock(csr_request.token, csr_request.no_such_resource):
+            self.csr.post_request('no/such/request')
+            self.assertEqual(requests.codes.NOT_FOUND, self.csr.status)
+            # The result is HTTP 404 message, so no error content to check
+
+    def test_timeout_during_request(self):
+        """Negative test of timeout during REST request."""
+        with httmock.HTTMock(csr_request.token, csr_request.timeout):
+            self.csr._do_request('GET', 'global/host-name')
+            self.assertEqual(requests.codes.REQUEST_TIMEOUT, self.csr.status)
+
+    def test_token_expired_on_request(self):
+        """Token expired before trying a REST request.
+
+        The mock is configured to return a 401 error on the first
+        attempt to reference the host name. Simulate expiration of
+        token by changing it.
+        """
+
+        with httmock.HTTMock(csr_request.token, csr_request.expired_request,
+                             csr_request.normal_get):
+            self.csr.token = '123'  # These are 44 characters, so won't match
+            content = self.csr._do_request('GET', 'global/host-name')
+            self.assertEqual(requests.codes.OK, self.csr.status)
+            self.assertIn('host-name', content)
+            self.assertNotEqual(None, content['host-name'])
+
+    def test_failed_to_obtain_token_for_request(self):
+        """Negative test of unauthorized user for REST request."""
+        self.csr.auth = ('stack', 'bogus')
+        with httmock.HTTMock(csr_request.token_unauthorized):
+            self.csr._do_request('GET', 'global/host-name')
+            self.assertEqual(requests.codes.UNAUTHORIZED, self.csr.status)
+
+
+class TestCsrRestIkePolicyCreate(base.BaseTestCase):
+
+    """Test IKE policy create REST requests."""
+
+    def setUp(self):
+        super(TestCsrRestIkePolicyCreate, self).setUp()
+        self.csr = csr_client.CsrRestClient('localhost', '10.10.10.10',
+                                            'stack', 'cisco')
+
+    def test_create_delete_ike_policy(self):
+        """Create and then delete IKE policy."""
+        with httmock.HTTMock(csr_request.token, csr_request.post,
+                             csr_request.normal_get):
+            policy_id = '2'
+            policy_info = {u'priority-id': u'%s' % policy_id,
+                           u'encryption': u'aes',
+                           u'hash': u'sha',
+                           u'dhGroup': 5,
+                           u'lifetime': 3600}
+            location = self.csr.create_ike_policy(policy_info)
+            self.assertEqual(requests.codes.CREATED, self.csr.status)
+            self.assertIn('vpn-svc/ike/policies/%s' % policy_id, location)
+            # Check the hard-coded items that get set as well...
+            content = self.csr.get_request(location, full_url=True)
+            self.assertEqual(requests.codes.OK, self.csr.status)
+            expected_policy = {u'kind': u'object#ike-policy',
+                               u'version': u'v1',
+                               u'local-auth-method': u'pre-share'}
+            expected_policy.update(policy_info)
+            self.assertEqual(expected_policy, content)
+        # Now delete and verify the IKE policy is gone
+        with httmock.HTTMock(csr_request.token, csr_request.delete,
+                             csr_request.no_such_resource):
+            self.csr.delete_ike_policy(policy_id)
+            self.assertEqual(requests.codes.NO_CONTENT, self.csr.status)
+            content = self.csr.get_request(location, full_url=True)
+            self.assertEqual(requests.codes.NOT_FOUND, self.csr.status)
+
+    def test_create_ike_policy_with_defaults(self):
+        """Create IKE policy using defaults for all optional values."""
+        with httmock.HTTMock(csr_request.token, csr_request.post,
+                             csr_request.get_defaults):
+            policy_id = '2'
+            policy_info = {u'priority-id': u'%s' % policy_id}
+            location = self.csr.create_ike_policy(policy_info)
+            self.assertEqual(requests.codes.CREATED, self.csr.status)
+            self.assertIn('vpn-svc/ike/policies/%s' % policy_id, location)
+            # Check the hard-coded items that get set as well...
+            content = self.csr.get_request(location, full_url=True)
+            self.assertEqual(requests.codes.OK, self.csr.status)
+            expected_policy = {u'kind': u'object#ike-policy',
+                               u'version': u'v1',
+                               u'encryption': u'des',
+                               u'hash': u'sha',
+                               u'dhGroup': 1,
+                               u'lifetime': 86400,
+                               # Lower level sets this, but it is the default
+                               u'local-auth-method': u'pre-share'}
+            expected_policy.update(policy_info)
+            self.assertEqual(expected_policy, content)
+
+    def test_create_duplicate_ike_policy(self):
+        """Negative test of trying to create a duplicate IKE policy."""
+        with httmock.HTTMock(csr_request.token, csr_request.post,
+                             csr_request.normal_get):
+            policy_id = '2'
+            policy_info = {u'priority-id': u'%s' % policy_id,
+                           u'encryption': u'aes',
+                           u'hash': u'sha',
+                           u'dhGroup': 5,
+                           u'lifetime': 3600}
+            location = self.csr.create_ike_policy(policy_info)
+            self.assertEqual(requests.codes.CREATED, self.csr.status)
+            self.assertIn('vpn-svc/ike/policies/%s' % policy_id, location)
+        with httmock.HTTMock(csr_request.token, csr_request.post_duplicate):
+            location = self.csr.create_ike_policy(policy_info)
+            self.assertEqual(requests.codes.BAD_REQUEST, self.csr.status)
+            expected = {u'error-code': -1,
+                        u'error-message': u'policy 2 exist, not allow to '
+                        u'update policy using POST method'}
+            self.assertDictContainsSubset(expected, location)
+
+
+class TestCsrRestIPSecPolicyCreate(base.BaseTestCase):
+
+    """Test IPSec policy create REST requests."""
+
+    def setUp(self):
+        super(TestCsrRestIPSecPolicyCreate, self).setUp()
+        self.csr = csr_client.CsrRestClient('localhost', '10.10.10.10',
+                                            'stack', 'cisco')
+
+    def test_create_delete_ipsec_policy(self):
+        """Create and then delete IPSec policy."""
+        with httmock.HTTMock(csr_request.token, csr_request.post,
+                             csr_request.normal_get):
+            policy_id = '123'
+            policy_info = {
+                u'policy-id': u'%s' % policy_id,
+                u'protection-suite': {
+                    u'esp-encryption': u'esp-aes',
+                    u'esp-authentication': u'esp-sha-hmac',
+                    u'ah': u'ah-sha-hmac',
+                },
+                u'lifetime-sec': 120,
+                u'pfs': u'group5',
+                u'anti-replay-window-size': u'128'
+            }
+            location = self.csr.create_ipsec_policy(policy_info)
+            self.assertEqual(requests.codes.CREATED, self.csr.status)
+            self.assertIn('vpn-svc/ipsec/policies/%s' % policy_id, location)
+            # Check the hard-coded items that get set as well...
+            content = self.csr.get_request(location, full_url=True)
+            self.assertEqual(requests.codes.OK, self.csr.status)
+            expected_policy = {u'kind': u'object#ipsec-policy',
+                               u'mode': u'tunnel',
+                               u'lifetime-kb': 4608000,
+                               u'idle-time': None}
+            expected_policy.update(policy_info)
+            self.assertEqual(expected_policy, content)
+        # Now delete and verify the IPSec policy is gone
+        with httmock.HTTMock(csr_request.token, csr_request.delete,
+                             csr_request.no_such_resource):
+            self.csr.delete_ipsec_policy(policy_id)
+            self.assertEqual(requests.codes.NO_CONTENT, self.csr.status)
+            content = self.csr.get_request(location, full_url=True)
+            self.assertEqual(requests.codes.NOT_FOUND, self.csr.status)
+
+    def test_create_ipsec_policy_with_defaults(self):
+        """Create IPSec policy with default for all optional values."""
+        with httmock.HTTMock(csr_request.token, csr_request.post,
+                             csr_request.get_defaults):
+            policy_id = '123'
+            policy_info = {
+                u'policy-id': u'%s' % policy_id,
+            }
+            location = self.csr.create_ipsec_policy(policy_info)
+            self.assertEqual(requests.codes.CREATED, self.csr.status)
+            self.assertIn('vpn-svc/ipsec/policies/%s' % policy_id, location)
+            # Check the hard-coded items that get set as well...
+            content = self.csr.get_request(location, full_url=True)
+            self.assertEqual(requests.codes.OK, self.csr.status)
+            expected_policy = {u'kind': u'object#ipsec-policy',
+                               u'mode': u'tunnel',
+                               u'protection-suite': {},
+                               u'lifetime-sec': 3600,
+                               u'pfs': u'Disable',
+                               u'anti-replay-window-size': u'None',
+                               u'lifetime-kb': 4608000,
+                               u'idle-time': None}
+            expected_policy.update(policy_info)
+            self.assertEqual(expected_policy, content)
+
+    def test_create_ipsec_policy_with_uuid(self):
+        """Create IPSec policy using UUID for id."""
+        with httmock.HTTMock(csr_request.token, csr_request.post,
+                             csr_request.normal_get):
+            policy_info = {
+                u'policy-id': u'%s' % dummy_uuid,
+                u'protection-suite': {
+                    u'esp-encryption': u'esp-aes',
+                    u'esp-authentication': u'esp-sha-hmac',
+                    u'ah': u'ah-sha-hmac',
+                },
+                u'lifetime-sec': 120,
+                u'pfs': u'group5',
+                u'anti-replay-window-size': u'128'
+            }
+            location = self.csr.create_ipsec_policy(policy_info)
+            self.assertEqual(requests.codes.CREATED, self.csr.status)
+            self.assertIn('vpn-svc/ipsec/policies/%s' % dummy_uuid,
+                          location)
+            # Check the hard-coded items that get set as well...
+            content = self.csr.get_request(location, full_url=True)
+            self.assertEqual(requests.codes.OK, self.csr.status)
+            expected_policy = {u'kind': u'object#ipsec-policy',
+                               u'mode': u'tunnel',
+                               u'lifetime-kb': 4608000,
+                               u'idle-time': None}
+            expected_policy.update(policy_info)
+            self.assertEqual(expected_policy, content)
+
+    def test_create_ipsec_policy_without_ah(self):
+        """Create IPSec policy."""
+        with httmock.HTTMock(csr_request.token, csr_request.post,
+                             csr_request.get_no_ah):
+            policy_id = '10'
+            policy_info = {
+                u'policy-id': u'%s' % policy_id,
+                u'protection-suite': {
+                    u'esp-encryption': u'esp-aes',
+                    u'esp-authentication': u'esp-sha-hmac',
+                },
+                u'lifetime-sec': 120,
+                u'pfs': u'group5',
+                u'anti-replay-window-size': u'128'
+            }
+            location = self.csr.create_ipsec_policy(policy_info)
+            self.assertEqual(requests.codes.CREATED, self.csr.status)
+            self.assertIn('vpn-svc/ipsec/policies/%s' % policy_id, location)
+            # Check the hard-coded items that get set as well...
+            content = self.csr.get_request(location, full_url=True)
+            self.assertEqual(requests.codes.OK, self.csr.status)
+            expected_policy = {u'kind': u'object#ipsec-policy',
+                               u'mode': u'tunnel',
+                               u'lifetime-kb': 4608000,
+                               u'idle-time': None}
+            expected_policy.update(policy_info)
+            self.assertEqual(expected_policy, content)
+
+    def test_invalid_ipsec_policy_lifetime(self):
+        """Failure test of IPSec policy with unsupported lifetime."""
+        with httmock.HTTMock(csr_request.token,
+                             csr_request.post_bad_lifetime):
+            policy_id = '123'
+            policy_info = {
+                u'policy-id': u'%s' % policy_id,
+                u'protection-suite': {
+                    u'esp-encryption': u'esp-aes',
+                    u'esp-authentication': u'esp-sha-hmac',
+                    u'ah': u'ah-sha-hmac',
+                },
+                u'lifetime-sec': 119,
+                u'pfs': u'group5',
+                u'anti-replay-window-size': u'128'
+            }
+            self.csr.create_ipsec_policy(policy_info)
+            self.assertEqual(requests.codes.BAD_REQUEST, self.csr.status)
+
+
+class TestCsrRestPreSharedKeyCreate(base.BaseTestCase):
+
+    """Test Pre-shared key (PSK) create REST requests."""
+
+    def setUp(self):
+        super(TestCsrRestPreSharedKeyCreate, self).setUp()
+        self.csr = csr_client.CsrRestClient('localhost', '10.10.10.10',
+                                            'stack', 'cisco')
+
+    def test_create_delete_pre_shared_key(self):
+        """Create and then delete a keyring entry for pre-shared key."""
+        with httmock.HTTMock(csr_request.token, csr_request.post,
+                             csr_request.normal_get):
+            psk_id = '5'
+            psk_info = {u'keyring-name': u'%s' % psk_id,
+                        u'pre-shared-key-list': [
+                            {u'key': u'super-secret',
+                             u'encrypted': False,
+                             u'peer-address': u'10.10.10.20/24'}
+                        ]}
+            location = self.csr.create_pre_shared_key(psk_info)
+            self.assertEqual(requests.codes.CREATED, self.csr.status)
+            self.assertIn('vpn-svc/ike/keyrings/%s' % psk_id, location)
+            # Check the hard-coded items that get set as well...
+            content = self.csr.get_request(location, full_url=True)
+            self.assertEqual(requests.codes.OK, self.csr.status)
+            expected_policy = {u'kind': u'object#ike-keyring'}
+            expected_policy.update(psk_info)
+            # Note: the peer CIDR is returned as an IP and mask
+            expected_policy[u'pre-shared-key-list'][0][u'peer-address'] = (
+                u'10.10.10.20 255.255.255.0')
+            self.assertEqual(expected_policy, content)
+        # Now delete and verify pre-shared key is gone
+        with httmock.HTTMock(csr_request.token, csr_request.delete,
+                             csr_request.no_such_resource):
+            self.csr.delete_pre_shared_key(psk_id)
+            self.assertEqual(requests.codes.NO_CONTENT, self.csr.status)
+            content = self.csr.get_request(location, full_url=True)
+            self.assertEqual(requests.codes.NOT_FOUND, self.csr.status)
+
+    def test_create_pre_shared_key_with_fqdn_peer(self):
+        """Create pre-shared key using FQDN for peer address."""
+        with httmock.HTTMock(csr_request.token, csr_request.post,
+                             csr_request.get_fqdn):
+            psk_id = '5'
+            psk_info = {u'keyring-name': u'%s' % psk_id,
+                        u'pre-shared-key-list': [
+                            {u'key': u'super-secret',
+                             u'encrypted': False,
+                             u'peer-address': u'cisco.com'}
+                        ]}
+            location = self.csr.create_pre_shared_key(psk_info)
+            self.assertEqual(requests.codes.CREATED, self.csr.status)
+            self.assertIn('vpn-svc/ike/keyrings/%s' % psk_id, location)
+            # Check the hard-coded items that get set as well...
+            content = self.csr.get_request(location, full_url=True)
+            self.assertEqual(requests.codes.OK, self.csr.status)
+            expected_policy = {u'kind': u'object#ike-keyring'}
+            expected_policy.update(psk_info)
+            self.assertEqual(expected_policy, content)
+
+    def test_create_pre_shared_key_with_duplicate_peer_address(self):
+        """Negative test of creating a second pre-shared key with same peer."""
+        with httmock.HTTMock(csr_request.token, csr_request.post,
+                             csr_request.normal_get):
+            psk_id = '5'
+            psk_info = {u'keyring-name': u'%s' % psk_id,
+                        u'pre-shared-key-list': [
+                            {u'key': u'super-secret',
+                             u'encrypted': False,
+                             u'peer-address': u'10.10.10.20/24'}
+                        ]}
+            location = self.csr.create_pre_shared_key(psk_info)
+            self.assertEqual(requests.codes.CREATED, self.csr.status)
+            self.assertIn('vpn-svc/ike/keyrings/%s' % psk_id, location)
+        with httmock.HTTMock(csr_request.token, csr_request.post_duplicate):
+            psk_id = u'6'
+            another_psk_info = {u'keyring-name': psk_id,
+                                u'pre-shared-key-list': [
+                                    {u'key': u'abc123def',
+                                     u'encrypted': False,
+                                     u'peer-address': u'10.10.10.20/24'}
+                                ]}
+            self.csr.create_ike_policy(another_psk_info)
+            self.assertEqual(requests.codes.BAD_REQUEST, self.csr.status)
+
+
+class TestCsrRestIPSecConnectionCreate(base.BaseTestCase):
+
+    """Test IPSec site-to-site connection REST requests.
+
+    This requires us to have first created an IKE policy, IPSec policy,
+    and pre-shared key, so it's more of an itegration test, when used
+    with a real CSR (as we can't mock out these pre-conditions.
+    """
+
+    def setUp(self):
+        super(TestCsrRestIPSecConnectionCreate, self).setUp()
+        self.csr = csr_client.CsrRestClient('localhost', '10.10.10.10',
+                                            'stack', 'cisco')
+
+    def _make_psk_for_test(self):
+        psk_id = generate_pre_shared_key_id()
+        self._remove_resource_for_test(self.csr.delete_pre_shared_key,
+                                       psk_id)
+        with httmock.HTTMock(csr_request.token, csr_request.post):
+            psk_info = {u'keyring-name': u'%d' % psk_id,
+                        u'pre-shared-key-list': [
+                            {u'key': u'super-secret',
+                             u'encrypted': False,
+                             u'peer-address': u'10.10.10.20/24'}
+                        ]}
+            self.csr.create_pre_shared_key(psk_info)
+            if self.csr.status != requests.codes.CREATED:
+                self.fail("Unable to create PSK for test case")
+            self.addCleanup(self._remove_resource_for_test,
+                            self.csr.delete_pre_shared_key, psk_id)
+            return psk_id
+
+    def _make_ike_policy_for_test(self):
+        policy_id = generate_ike_policy_id()
+        self._remove_resource_for_test(self.csr.delete_ike_policy,
+                                       policy_id)
+        with httmock.HTTMock(csr_request.token, csr_request.post):
+            policy_info = {u'priority-id': u'%d' % policy_id,
+                           u'encryption': u'aes',
+                           u'hash': u'sha',
+                           u'dhGroup': 5,
+                           u'lifetime': 3600}
+            self.csr.create_ike_policy(policy_info)
+            if self.csr.status != requests.codes.CREATED:
+                self.fail("Unable to create IKE policy for test case")
+            self.addCleanup(self._remove_resource_for_test,
+                            self.csr.delete_ike_policy, policy_id)
+            return policy_id
+
+    def _make_ipsec_policy_for_test(self):
+        policy_id = generate_ipsec_policy_id()
+        self._remove_resource_for_test(self.csr.delete_ipsec_policy,
+                                       policy_id)
+        with httmock.HTTMock(csr_request.token, csr_request.post):
+            policy_info = {
+                u'policy-id': u'%d' % policy_id,
+                u'protection-suite': {
+                    u'esp-encryption': u'esp-aes',
+                    u'esp-authentication': u'esp-sha-hmac',
+                    u'ah': u'ah-sha-hmac',
+                },
+                u'lifetime-sec': 120,
+                u'pfs': u'group5',
+                u'anti-replay-window-size': u'64'
+            }
+            self.csr.create_ipsec_policy(policy_info)
+            if self.csr.status != requests.codes.CREATED:
+                self.fail("Unable to create IPSec policy for test case")
+            self.addCleanup(self._remove_resource_for_test,
+                            self.csr.delete_ipsec_policy, policy_id)
+            return policy_id
+
+    def _remove_resource_for_test(self, delete_resource, resource_id):
+        with httmock.HTTMock(csr_request.token, csr_request.delete):
+            delete_resource(resource_id)
+
+    def _prepare_for_site_conn_create(self, skip_psk=False, skip_ike=False,
+                                      skip_ipsec=False):
+        """Create the policies and PSK so can then create site conn."""
+        if not skip_psk:
+            self._make_psk_for_test()
+        if not skip_ike:
+            self._make_ike_policy_for_test()
+        if not skip_ipsec:
+            ipsec_policy_id = self._make_ipsec_policy_for_test()
+        else:
+            ipsec_policy_id = generate_ipsec_policy_id()
+        # Note: Use same ID number for tunnel and IPSec policy, so that when
+        # GET tunnel info, the mocks can infer the IPSec policy ID from the
+        # tunnel number.
+        return (ipsec_policy_id, ipsec_policy_id)
+
+    def test_create_delete_ipsec_connection(self):
+        """Create and then delete an IPSec connection."""
+        tunnel_id, ipsec_policy_id = self._prepare_for_site_conn_create()
+        with httmock.HTTMock(csr_request.token, csr_request.post,
+                             csr_request.normal_get):
+            connection_info = {
+                u'vpn-interface-name': u'Tunnel%d' % tunnel_id,
+                u'ipsec-policy-id': u'%d' % ipsec_policy_id,
+                u'mtu': 1500,
+                u'local-device': {u'ip-address': u'10.3.0.1/24',
+                                  u'tunnel-ip-address': u'10.10.10.10'},
+                u'remote-device': {u'tunnel-ip-address': u'10.10.10.20'}
+            }
+            location = self.csr.create_ipsec_connection(connection_info)
+            self.assertEqual(requests.codes.CREATED, self.csr.status)
+            self.addCleanup(self._remove_resource_for_test,
+                            self.csr.delete_ipsec_connection,
+                            'Tunnel%d' % tunnel_id)
+            self.assertIn('vpn-svc/site-to-site/Tunnel%d' % tunnel_id,
+                          location)
+            # Check the hard-coded items that get set as well...
+            content = self.csr.get_request(location, full_url=True)
+            self.assertEqual(requests.codes.OK, self.csr.status)
+            expected_connection = {u'kind': u'object#vpn-site-to-site',
+                                   u'ike-profile-id': None,
+                                   u'mtu': 1500,
+                                   u'ip-version': u'ipv4'}
+            expected_connection.update(connection_info)
+            self.assertEqual(expected_connection, content)
+        # Now delete and verify that site-to-site connection is gone
+        with httmock.HTTMock(csr_request.token, csr_request.delete,
+                             csr_request.no_such_resource):
+            # Only delete connection. Cleanup will take care of prerequisites
+            self.csr.delete_ipsec_connection('Tunnel%d' % tunnel_id)
+            self.assertEqual(requests.codes.NO_CONTENT, self.csr.status)
+            content = self.csr.get_request(location, full_url=True)
+            self.assertEqual(requests.codes.NOT_FOUND, self.csr.status)
+
+    def test_create_ipsec_connection_with_no_tunnel_subnet(self):
+        """Create an IPSec connection without an IP address on tunnel."""
+        tunnel_id, ipsec_policy_id = self._prepare_for_site_conn_create()
+        with httmock.HTTMock(csr_request.token, csr_request.post,
+                             csr_request.get_unnumbered):
+            connection_info = {
+                u'vpn-interface-name': u'Tunnel%d' % tunnel_id,
+                u'ipsec-policy-id': u'%d' % ipsec_policy_id,
+                u'local-device': {u'ip-address': u'GigabitEthernet3',
+                                  u'tunnel-ip-address': u'10.10.10.10'},
+                u'remote-device': {u'tunnel-ip-address': u'10.10.10.20'}
+            }
+            location = self.csr.create_ipsec_connection(connection_info)
+            self.assertEqual(requests.codes.CREATED, self.csr.status)
+            self.addCleanup(self._remove_resource_for_test,
+                            self.csr.delete_ipsec_connection,
+                            'Tunnel%d' % tunnel_id)
+            self.assertIn('vpn-svc/site-to-site/Tunnel%d' % tunnel_id,
+                          location)
+            # Check the hard-coded items that get set as well...
+            content = self.csr.get_request(location, full_url=True)
+            if csr_request.FIXED_CSCum50512:
+                self.assertEqual(requests.codes.OK, self.csr.status)
+                expected_connection = {u'kind': u'object#vpn-site-to-site',
+                                       u'ip-version': u'ipv4'}
+                expected_connection.update(connection_info)
+                expected_connection[u'local-device'][u'ip-address'] = (
+                    u'unnumbered GigabitEthernet3')
+                self.assertEqual(expected_connection, content)
+            else:
+                self.assertEqual(requests.codes.INTERNAL_SERVER_ERROR,
+                                 self.csr.status)
+
+    def test_create_ipsec_connection_no_pre_shared_key(self):
+        """Test of connection create without associated pre-shared key.
+
+        The CSR will create the connection, but will not be able to pass
+        traffic without the pre-shared key.
+        """
+
+        tunnel_id, ipsec_policy_id = self._prepare_for_site_conn_create(
+            skip_psk=True)
+        with httmock.HTTMock(csr_request.token, csr_request.post,
+                             csr_request.normal_get):
+            connection_info = {
+                u'vpn-interface-name': u'Tunnel%d' % tunnel_id,
+                u'ipsec-policy-id': u'%d' % ipsec_policy_id,
+                u'mtu': 1500,
+                u'local-device': {u'ip-address': u'10.3.0.1/24',
+                                  u'tunnel-ip-address': u'10.10.10.10'},
+                u'remote-device': {u'tunnel-ip-address': u'10.10.10.20'}
+            }
+            location = self.csr.create_ipsec_connection(connection_info)
+            self.assertEqual(requests.codes.CREATED, self.csr.status)
+            self.addCleanup(self._remove_resource_for_test,
+                            self.csr.delete_ipsec_connection,
+                            'Tunnel%d' % tunnel_id)
+            self.assertIn('vpn-svc/site-to-site/Tunnel%d' % tunnel_id,
+                          location)
+            # Check the hard-coded items that get set as well...
+            content = self.csr.get_request(location, full_url=True)
+            self.assertEqual(requests.codes.OK, self.csr.status)
+            expected_connection = {u'kind': u'object#vpn-site-to-site',
+                                   u'ike-profile-id': None,
+                                   u'mtu': 1500,
+                                   u'ip-version': u'ipv4'}
+            expected_connection.update(connection_info)
+            self.assertEqual(expected_connection, content)
+
+    def test_create_ipsec_connection_with_default_ike_policy(self):
+        """Test of connection create without IKE policy (uses default).
+
+        Without an IKE policy, the CSR will use a built-in default IKE
+        policy setting for the connection.
+        """
+
+        tunnel_id, ipsec_policy_id = self._prepare_for_site_conn_create(
+            skip_ike=True)
+        with httmock.HTTMock(csr_request.token, csr_request.post,
+                             csr_request.normal_get):
+            connection_info = {
+                u'vpn-interface-name': u'Tunnel%d' % tunnel_id,
+                u'ipsec-policy-id': u'%d' % ipsec_policy_id,
+                u'mtu': 1500,
+                u'local-device': {u'ip-address': u'10.3.0.1/24',
+                                  u'tunnel-ip-address': u'10.10.10.10'},
+                u'remote-device': {u'tunnel-ip-address': u'10.10.10.20'}
+            }
+            location = self.csr.create_ipsec_connection(connection_info)
+            self.assertEqual(requests.codes.CREATED, self.csr.status)
+            self.addCleanup(self._remove_resource_for_test,
+                            self.csr.delete_ipsec_connection,
+                            'Tunnel%d' % tunnel_id)
+            self.assertIn('vpn-svc/site-to-site/Tunnel%d' % tunnel_id,
+                          location)
+            # Check the hard-coded items that get set as well...
+            content = self.csr.get_request(location, full_url=True)
+            self.assertEqual(requests.codes.OK, self.csr.status)
+            expected_connection = {u'kind': u'object#vpn-site-to-site',
+                                   u'ike-profile-id': None,
+                                   u'mtu': 1500,
+                                   u'ip-version': u'ipv4'}
+            expected_connection.update(connection_info)
+            self.assertEqual(expected_connection, content)
+
+    def test_create_ipsec_connection_missing_ipsec_policy(self):
+        """Negative test of connection create without IPSec policy."""
+        tunnel_id, ipsec_policy_id = self._prepare_for_site_conn_create(
+            skip_ipsec=True)
+        with httmock.HTTMock(csr_request.token,
+                             csr_request.post_missing_ipsec_policy):
+            connection_info = {
+                u'vpn-interface-name': u'Tunnel%d' % tunnel_id,
+                u'ipsec-policy-id': u'%d' % ipsec_policy_id,
+                u'local-device': {u'ip-address': u'10.3.0.1/24',
+                                  u'tunnel-ip-address': u'10.10.10.10'},
+                u'remote-device': {u'tunnel-ip-address': u'10.10.10.20'}
+            }
+            self.csr.create_ipsec_connection(connection_info)
+            self.assertEqual(requests.codes.BAD_REQUEST, self.csr.status)
+
+    def test_create_ipsec_connection_conficting_tunnel_ip(self):
+        """Negative test of connection create with conflicting tunnel IP.
+
+        The GigabitEthernet3 interface has an IP of 10.2.0.6. This will
+        try a connection create with an IP that is on the same subnet.
+        """
+
+        tunnel_id, ipsec_policy_id = self._prepare_for_site_conn_create()
+        with httmock.HTTMock(csr_request.token, csr_request.post_bad_ip):
+            connection_info = {
+                u'vpn-interface-name': u'Tunnel%d' % tunnel_id,
+                u'ipsec-policy-id': u'%d' % ipsec_policy_id,
+                u'local-device': {u'ip-address': u'10.2.0.10/24',
+                                  u'tunnel-ip-address': u'10.10.10.10'},
+                u'remote-device': {u'tunnel-ip-address': u'10.10.10.20'}
+            }
+            self.csr.create_ipsec_connection(connection_info)
+            self.assertEqual(requests.codes.BAD_REQUEST, self.csr.status)
+
+    def test_create_ipsec_connection_with_max_mtu(self):
+        """Create an IPSec connection with max MTU value."""
+        tunnel_id, ipsec_policy_id = self._prepare_for_site_conn_create()
+        with httmock.HTTMock(csr_request.token, csr_request.post,
+                             csr_request.get_mtu):
+            connection_info = {
+                u'vpn-interface-name': u'Tunnel%d' % tunnel_id,
+                u'ipsec-policy-id': u'%d' % ipsec_policy_id,
+                u'mtu': 9192,
+                u'local-device': {u'ip-address': u'10.3.0.1/24',
+                                  u'tunnel-ip-address': u'10.10.10.10'},
+                u'remote-device': {u'tunnel-ip-address': u'10.10.10.20'}
+            }
+            location = self.csr.create_ipsec_connection(connection_info)
+            self.assertEqual(requests.codes.CREATED, self.csr.status)
+            self.addCleanup(self._remove_resource_for_test,
+                            self.csr.delete_ipsec_connection,
+                            'Tunnel%d' % tunnel_id)
+            self.assertIn('vpn-svc/site-to-site/Tunnel%d' % tunnel_id,
+                          location)
+            # Check the hard-coded items that get set as well...
+            content = self.csr.get_request(location, full_url=True)
+            self.assertEqual(requests.codes.OK, self.csr.status)
+            expected_connection = {u'kind': u'object#vpn-site-to-site',
+                                   u'ike-profile-id': None,
+                                   u'ip-version': u'ipv4'}
+            expected_connection.update(connection_info)
+            self.assertEqual(expected_connection, content)
+
+    def test_create_ipsec_connection_with_bad_mtu(self):
+        """Negative test of connection create with unsupported MTU value."""
+        tunnel_id, ipsec_policy_id = self._prepare_for_site_conn_create()
+        with httmock.HTTMock(csr_request.token, csr_request.post_bad_mtu):
+            connection_info = {
+                u'vpn-interface-name': u'Tunnel%d' % tunnel_id,
+                u'ipsec-policy-id': u'%d' % ipsec_policy_id,
+                u'mtu': 9193,
+                u'local-device': {u'ip-address': u'10.3.0.1/24',
+                                  u'tunnel-ip-address': u'10.10.10.10'},
+                u'remote-device': {u'tunnel-ip-address': u'10.10.10.20'}
+            }
+            self.csr.create_ipsec_connection(connection_info)
+            self.assertEqual(requests.codes.BAD_REQUEST, self.csr.status)
+
+    def test_status_when_no_tunnels_exist(self):
+        """Get status, when there are no tunnels."""
+        with httmock.HTTMock(csr_request.token, csr_request.get_none):
+            tunnels = self.csr.read_tunnel_statuses()
+            self.assertEqual(requests.codes.OK, self.csr.status)
+            self.assertEqual([], tunnels)
+
+    def test_status_for_one_tunnel(self):
+        """Get status of one tunnel."""
+        # Create the IPsec site-to-site connection first
+        tunnel_id, ipsec_policy_id = self._prepare_for_site_conn_create()
+        tunnel_id = 123  # Must hard code to work with mock
+        with httmock.HTTMock(csr_request.token, csr_request.post,
+                             csr_request.normal_get):
+            connection_info = {
+                u'vpn-interface-name': u'Tunnel123',
+                u'ipsec-policy-id': u'%d' % ipsec_policy_id,
+                u'local-device': {u'ip-address': u'10.3.0.1/24',
+                                  u'tunnel-ip-address': u'10.10.10.10'},
+                u'remote-device': {u'tunnel-ip-address': u'10.10.10.20'}
+            }
+            location = self.csr.create_ipsec_connection(connection_info)
+            self.assertEqual(requests.codes.CREATED, self.csr.status)
+            self.addCleanup(self._remove_resource_for_test,
+                            self.csr.delete_ipsec_connection,
+                            u'Tunnel123')
+            self.assertIn('vpn-svc/site-to-site/Tunnel%d' % tunnel_id,
+                          location)
+        with httmock.HTTMock(csr_request.token, csr_request.normal_get):
+            tunnels = self.csr.read_tunnel_statuses()
+            self.assertEqual(requests.codes.OK, self.csr.status)
+            self.assertEqual([(u'Tunnel123', u'DOWN-NEGOTIATING'), ], tunnels)
+
+
+class TestCsrRestIkeKeepaliveCreate(base.BaseTestCase):
+
+    """Test IKE keepalive REST requests.
+
+    This is a global configuration that will apply to all VPN tunnels and
+    is used to specify Dead Peer Detection information. Currently, the API
+    supports DELETE API, but a bug has been created to remove the API and
+    add an indicator of when the capability is disabled.
+    """
+
+    def setUp(self):
+        super(TestCsrRestIkeKeepaliveCreate, self).setUp()
+        self.csr = csr_client.CsrRestClient('localhost', '10.10.10.10',
+                                            'stack', 'cisco')
+
+    def test_configure_ike_keepalive(self):
+        """Set IKE keep-alive (aka Dead Peer Detection) for the CSR."""
+        with httmock.HTTMock(csr_request.token, csr_request.put,
+                             csr_request.normal_get):
+            keepalive_info = {'interval': 60, 'retry': 4}
+            self.csr.configure_ike_keepalive(keepalive_info)
+            self.assertEqual(requests.codes.NO_CONTENT, self.csr.status)
+            content = self.csr.get_request('vpn-svc/ike/keepalive')
+            self.assertEqual(requests.codes.OK, self.csr.status)
+            expected = {'periodic': False}
+            expected.update(keepalive_info)
+            self.assertDictContainsSubset(expected, content)
+
+    def test_disable_ike_keepalive(self):
+        """Disable IKE keep-alive (aka Dead Peer Detection) for the CSR."""
+        with httmock.HTTMock(csr_request.token, csr_request.delete,
+                             csr_request.put, csr_request.get_not_configured):
+            if csr_request.FIXED_CSCum10324:
+                # TODO(pcm) Is this how to disable?
+                keepalive_info = {'interval': 0, 'retry': 4}
+                self.csr.configure_ike_keepalive(keepalive_info)
+                self.assertEqual(requests.codes.NO_CONTENT, self.csr.status)
+            else:
+                self.csr.delete_request('vnc-svc/ike/keepalive')
+                self.assertIn(self.csr.status,
+                              (requests.codes.NO_CONTENT,
+                               requests.codes.NOT_FOUND))
+                self.csr.get_request('vpn-svc/ike/keepalive')
+                self.assertEqual(requests.codes.NOT_FOUND, self.csr.status)
+
+
+class TestCsrRestStaticRoute(base.BaseTestCase):
+
+    """Test static route REST requests.
+
+    A static route is added for the peer's private network. Would create
+    a route for each of the peer CIDRs specified for the VPN connection.
+    """
+
+    def setUp(self):
+        super(TestCsrRestStaticRoute, self).setUp()
+        self.csr = csr_client.CsrRestClient('localhost', '10.10.10.10',
+                                            'stack', 'cisco')
+
+    def test_create_delete_static_route(self):
+        """Create and then delete a static route for the tunnel."""
+        cidr = u'10.1.0.0/24'
+        interface = u'GigabitEthernet1'
+        expected_id = '10.1.0.0_24_GigabitEthernet1'
+        with httmock.HTTMock(csr_request.token, csr_request.post,
+                             csr_request.normal_get):
+            route_info = {u'destination-network': cidr,
+                          u'outgoing-interface': interface}
+            location = self.csr.create_static_route(route_info)
+            self.assertEqual(requests.codes.CREATED, self.csr.status)
+            self.assertIn('routing-svc/static-routes/%s' % expected_id,
+                          location)
+            # Check the hard-coded items that get set as well...
+            content = self.csr.get_request(location, full_url=True)
+            self.assertEqual(requests.codes.OK, self.csr.status)
+            expected_route = {u'kind': u'object#static-route',
+                              u'next-hop-router': None,
+                              u'admin-distance': 1}
+            expected_route.update(route_info)
+            self.assertEqual(expected_route, content)
+        # Now delete and verify that static route is gone
+        with httmock.HTTMock(csr_request.token, csr_request.delete,
+                             csr_request.no_such_resource):
+            route_id = csr_client.make_route_id(cidr, interface)
+            self.csr.delete_static_route(route_id)
+            self.assertEqual(requests.codes.NO_CONTENT, self.csr.status)
+            content = self.csr.get_request(location, full_url=True)
+            self.assertEqual(requests.codes.NOT_FOUND, self.csr.status)
diff --git a/neutron/tests/unit/services/vpn/device_drivers/test_cisco_ipsec.py b/neutron/tests/unit/services/vpn/device_drivers/test_cisco_ipsec.py
new file mode 100644 (file)
index 0000000..924ff44
--- /dev/null
@@ -0,0 +1,1386 @@
+# 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.
+#
+# @author: Paul Michali, Cisco Systems, Inc.
+
+import httplib
+import os
+import tempfile
+
+import mock
+
+from neutron import context
+from neutron.openstack.common import uuidutils
+from neutron.plugins.common import constants
+from neutron.services.vpn.device_drivers import (
+    cisco_csr_rest_client as csr_client)
+from neutron.services.vpn.device_drivers import cisco_ipsec as ipsec_driver
+from neutron.tests import base
+
+_uuid = uuidutils.generate_uuid
+FAKE_HOST = 'fake_host'
+FAKE_ROUTER_ID = _uuid()
+FAKE_VPN_SERVICE = {
+    'id': _uuid(),
+    'router_id': FAKE_ROUTER_ID,
+    'admin_state_up': True,
+    'status': constants.PENDING_CREATE,
+    'subnet': {'cidr': '10.0.0.0/24'},
+    'ipsec_site_connections': [
+        {'peer_cidrs': ['20.0.0.0/24',
+                        '30.0.0.0/24']},
+        {'peer_cidrs': ['40.0.0.0/24',
+                        '50.0.0.0/24']}]
+}
+FIND_CFG_FOR_CSRS = ('neutron.services.vpn.device_drivers.cisco_ipsec.'
+                     'find_available_csrs_from_config')
+
+
+class TestCiscoCsrIPSecConnection(base.BaseTestCase):
+    def setUp(self):
+        super(TestCiscoCsrIPSecConnection, self).setUp()
+        self.addCleanup(mock.patch.stopall)
+        self.conn_info = {
+            u'id': '123',
+            u'status': constants.PENDING_CREATE,
+            u'admin_state_up': True,
+            'psk': 'secret',
+            'peer_address': '192.168.1.2',
+            'peer_cidrs': ['10.1.0.0/24', '10.2.0.0/24'],
+            'mtu': 1500,
+            'ike_policy': {'auth_algorithm': 'sha1',
+                           'encryption_algorithm': 'aes-128',
+                           'pfs': 'Group5',
+                           'ike_version': 'v1',
+                           'lifetime_units': 'seconds',
+                           'lifetime_value': 3600},
+            'ipsec_policy': {'transform_protocol': 'ah',
+                             'encryption_algorithm': 'aes-128',
+                             'auth_algorithm': 'sha1',
+                             'pfs': 'group5',
+                             'lifetime_units': 'seconds',
+                             'lifetime_value': 3600},
+            'cisco': {'site_conn_id': 'Tunnel0',
+                      'ike_policy_id': 222,
+                      'ipsec_policy_id': 333,
+                      # TODO(pcm) FUTURE use vpnservice['external_ip']
+                      'router_public_ip': '172.24.4.23'}
+        }
+        self.csr = mock.Mock(spec=csr_client.CsrRestClient)
+        self.csr.status = 201  # All calls to CSR REST API succeed
+        self.ipsec_conn = ipsec_driver.CiscoCsrIPSecConnection(self.conn_info,
+                                                               self.csr)
+
+    def test_create_ipsec_site_connection(self):
+        """Ensure all steps are done to create an IPSec site connection.
+
+        Verify that each of the driver calls occur (in order), and
+        the right information is stored for later deletion.
+        """
+        expected = ['create_pre_shared_key',
+                    'create_ike_policy',
+                    'create_ipsec_policy',
+                    'create_ipsec_connection',
+                    'create_static_route',
+                    'create_static_route']
+        expected_rollback_steps = [
+            ipsec_driver.RollbackStep(action='pre_shared_key',
+                                      resource_id='123',
+                                      title='Pre-Shared Key'),
+            ipsec_driver.RollbackStep(action='ike_policy',
+                                      resource_id=222,
+                                      title='IKE Policy'),
+            ipsec_driver.RollbackStep(action='ipsec_policy',
+                                      resource_id=333,
+                                      title='IPSec Policy'),
+            ipsec_driver.RollbackStep(action='ipsec_connection',
+                                      resource_id='Tunnel0',
+                                      title='IPSec Connection'),
+            ipsec_driver.RollbackStep(action='static_route',
+                                      resource_id='10.1.0.0_24_Tunnel0',
+                                      title='Static Route'),
+            ipsec_driver.RollbackStep(action='static_route',
+                                      resource_id='10.2.0.0_24_Tunnel0',
+                                      title='Static Route')]
+        self.ipsec_conn.create_ipsec_site_connection(mock.Mock(),
+                                                     self.conn_info)
+        client_calls = [c[0] for c in self.csr.method_calls]
+        self.assertEqual(expected, client_calls)
+        self.assertEqual(expected_rollback_steps, self.ipsec_conn.steps)
+
+    def test_create_ipsec_site_connection_with_rollback(self):
+        """Failure test of IPSec site conn creation that fails and rolls back.
+
+        Simulate a failure in the last create step (making routes for the
+        peer networks), and ensure that the create steps are called in
+        order (except for create_static_route), and that the delete
+        steps are called in reverse order. At the end, there should be no
+        rollback infromation for the connection.
+        """
+        def fake_route_check_fails(*args, **kwargs):
+            if args[0] == 'Static Route':
+                # So that subsequent calls to CSR rest client (for rollback)
+                # will fake as passing.
+                self.csr.status = httplib.NO_CONTENT
+                raise ipsec_driver.CsrResourceCreateFailure(resource=args[0],
+                                                            which=args[1])
+
+        with mock.patch.object(ipsec_driver.CiscoCsrIPSecConnection,
+                               '_check_create',
+                               side_effect=fake_route_check_fails):
+
+            expected = ['create_pre_shared_key',
+                        'create_ike_policy',
+                        'create_ipsec_policy',
+                        'create_ipsec_connection',
+                        'create_static_route',
+                        'delete_ipsec_connection',
+                        'delete_ipsec_policy',
+                        'delete_ike_policy',
+                        'delete_pre_shared_key']
+            self.ipsec_conn.create_ipsec_site_connection(mock.Mock(),
+                                                         self.conn_info)
+            client_calls = [c[0] for c in self.csr.method_calls]
+            self.assertEqual(expected, client_calls)
+            self.assertEqual([], self.ipsec_conn.steps)
+
+    def test_create_verification_with_error(self):
+        """Negative test of create check step had failed."""
+        self.csr.status = httplib.NOT_FOUND
+        self.assertRaises(ipsec_driver.CsrResourceCreateFailure,
+                          self.ipsec_conn._check_create, 'name', 'id')
+
+    def test_failure_with_invalid_create_step(self):
+        """Negative test of invalid create step (programming error)."""
+        self.ipsec_conn.steps = []
+        try:
+            self.ipsec_conn.do_create_action('bogus', None, '123', 'Bad Step')
+        except ipsec_driver.CsrResourceCreateFailure:
+            pass
+        else:
+            self.fail('Expected exception with invalid create step')
+
+    def test_failure_with_invalid_delete_step(self):
+        """Negative test of invalid delete step (programming error)."""
+        self.ipsec_conn.steps = [ipsec_driver.RollbackStep(action='bogus',
+                                                           resource_id='123',
+                                                           title='Bogus Step')]
+        try:
+            self.ipsec_conn.do_rollback()
+        except ipsec_driver.CsrResourceCreateFailure:
+            pass
+        else:
+            self.fail('Expected exception with invalid delete step')
+
+    def test_delete_ipsec_connection(self):
+        # TODO(pcm) implement
+        pass
+
+
+class TestCiscoCsrIPsecConnectionCreateTransforms(base.BaseTestCase):
+
+    """Verifies that config info is prepared/transformed correctly."""
+
+    def setUp(self):
+        super(TestCiscoCsrIPsecConnectionCreateTransforms, self).setUp()
+        self.addCleanup(mock.patch.stopall)
+        self.conn_info = {
+            u'id': '123',
+            u'status': constants.PENDING_CREATE,
+            u'admin_state_up': True,
+            'psk': 'secret',
+            'peer_address': '192.168.1.2',
+            'peer_cidrs': ['10.1.0.0/24', '10.2.0.0/24'],
+            'mtu': 1500,
+            'ike_policy': {'auth_algorithm': 'sha1',
+                           'encryption_algorithm': 'aes-128',
+                           'pfs': 'Group5',
+                           'ike_version': 'v1',
+                           'lifetime_units': 'seconds',
+                           'lifetime_value': 3600},
+            'ipsec_policy': {'transform_protocol': 'ah',
+                             'encryption_algorithm': 'aes-128',
+                             'auth_algorithm': 'sha1',
+                             'pfs': 'group5',
+                             'lifetime_units': 'seconds',
+                             'lifetime_value': 3600},
+            'cisco': {'site_conn_id': 'Tunnel0',
+                      'ike_policy_id': 222,
+                      'ipsec_policy_id': 333,
+                      # TODO(pcm) get from vpnservice['external_ip']
+                      'router_public_ip': '172.24.4.23'}
+        }
+        self.ipsec_conn = ipsec_driver.CiscoCsrIPSecConnection(self.conn_info,
+                                                               mock.Mock())
+
+    def test_invalid_attribute(self):
+        """Negative test of unknown attribute - programming error."""
+        self.assertRaises(ipsec_driver.CsrDriverMismatchError,
+                          self.ipsec_conn.translate_dialect,
+                          'ike_policy', 'unknown_attr', self.conn_info)
+
+    def test_driver_unknown_mapping(self):
+        """Negative test of service driver providing unknown value to map."""
+        self.conn_info['ike_policy']['pfs'] = "unknown_value"
+        self.assertRaises(ipsec_driver.CsrUnknownMappingError,
+                          self.ipsec_conn.translate_dialect,
+                          'ike_policy', 'pfs', self.conn_info['ike_policy'])
+
+    def test_psk_create_info(self):
+        """Ensure that pre-shared key info is created correctly."""
+        expected = {u'keyring-name': '123',
+                    u'pre-shared-key-list': [
+                        {u'key': 'secret',
+                         u'encrypted': False,
+                         u'peer-address': '192.168.1.2'}]}
+        psk_id = self.conn_info['id']
+        psk_info = self.ipsec_conn.create_psk_info(psk_id, self.conn_info)
+        self.assertEqual(expected, psk_info)
+
+    def test_create_ike_policy_info(self):
+        """Ensure that IKE policy info is mapped/created correctly."""
+        expected = {u'priority-id': 222,
+                    u'encryption': u'aes',
+                    u'hash': u'sha',
+                    u'dhGroup': 5,
+                    u'version': u'v1',
+                    u'lifetime': 3600}
+        policy_id = self.conn_info['cisco']['ike_policy_id']
+        policy_info = self.ipsec_conn.create_ike_policy_info(policy_id,
+                                                             self.conn_info)
+        self.assertEqual(expected, policy_info)
+
+    def test_create_ike_policy_info_non_defaults(self):
+        """Ensure that IKE policy info with different values."""
+        self.conn_info['ike_policy'] = {
+            'auth_algorithm': 'sha1',
+            'encryption_algorithm': 'aes-256',
+            'pfs': 'Group14',
+            'ike_version': 'v1',
+            'lifetime_units': 'seconds',
+            'lifetime_value': 60
+        }
+        expected = {u'priority-id': 222,
+                    u'encryption': u'aes',  # TODO(pcm): fix
+                    u'hash': u'sha',
+                    u'dhGroup': 14,
+                    u'version': u'v1',
+                    u'lifetime': 60}
+        policy_id = self.conn_info['cisco']['ike_policy_id']
+        policy_info = self.ipsec_conn.create_ike_policy_info(policy_id,
+                                                             self.conn_info)
+        self.assertEqual(expected, policy_info)
+
+    def test_ipsec_policy_info(self):
+        """Ensure that IPSec policy info is mapped/created correctly."""
+        expected = {u'policy-id': 333,
+                    u'protection-suite': {
+                        u'esp-encryption': u'esp-aes',
+                        u'esp-authentication': u'esp-sha-hmac',
+                        u'ah': u'ah-sha-hmac'
+                    },
+                    u'lifetime-sec': 3600,
+                    u'pfs': u'group5',
+                    u'anti-replay-window-size': u'64'}
+        ipsec_policy_id = self.conn_info['cisco']['ipsec_policy_id']
+        policy_info = self.ipsec_conn.create_ipsec_policy_info(ipsec_policy_id,
+                                                               self.conn_info)
+        self.assertEqual(expected, policy_info)
+
+    def test_ipsec_policy_info_non_defaults(self):
+        """Create/map IPSec policy info with different values."""
+        self.conn_info['ipsec_policy'] = {'transform_protocol': 'esp',
+                                          'encryption_algorithm': '3des',
+                                          'auth_algorithm': 'sha1',
+                                          'pfs': 'group14',
+                                          'lifetime_units': 'seconds',
+                                          'lifetime_value': 120}
+        expected = {u'policy-id': 333,
+                    u'protection-suite': {
+                        u'esp-encryption': u'esp-3des',
+                        u'esp-authentication': u'esp-sha-hmac'
+                    },
+                    u'lifetime-sec': 120,
+                    u'pfs': u'group14',
+                    u'anti-replay-window-size': u'64'}
+        ipsec_policy_id = self.conn_info['cisco']['ipsec_policy_id']
+        policy_info = self.ipsec_conn.create_ipsec_policy_info(ipsec_policy_id,
+                                                               self.conn_info)
+        self.assertEqual(expected, policy_info)
+
+    def test_site_connection_info(self):
+        """Ensure site-to-site connection info is created/mapped correctly."""
+        expected = {u'vpn-interface-name': 'Tunnel0',
+                    u'ipsec-policy-id': 333,
+                    u'local-device': {
+                        u'ip-address': u'GigabitEthernet3',
+                        u'tunnel-ip-address': u'172.24.4.23'
+                    },
+                    u'remote-device': {
+                        u'tunnel-ip-address': '192.168.1.2'
+                    },
+                    u'mtu': 1500}
+        ipsec_policy_id = self.conn_info['cisco']['ipsec_policy_id']
+        site_conn_id = self.conn_info['cisco']['site_conn_id']
+        conn_info = self.ipsec_conn.create_site_connection_info(
+            site_conn_id, ipsec_policy_id, self.conn_info)
+        self.assertEqual(expected, conn_info)
+
+    def test_static_route_info(self):
+        """Create static route info for peer CIDRs."""
+        expected = [('10.1.0.0_24_Tunnel0',
+                     {u'destination-network': '10.1.0.0/24',
+                      u'outgoing-interface': 'Tunnel0'}),
+                    ('10.2.0.0_24_Tunnel0',
+                     {u'destination-network': '10.2.0.0/24',
+                      u'outgoing-interface': 'Tunnel0'})]
+#         self.driver.csr.make_route_id.side_effect = ['10.1.0.0_24_Tunnel0',
+#                                                      '10.2.0.0_24_Tunnel0']
+        site_conn_id = self.conn_info['cisco']['site_conn_id']
+        routes_info = self.ipsec_conn.create_routes_info(site_conn_id,
+                                                         self.conn_info)
+        self.assertEqual(2, len(routes_info))
+        self.assertEqual(expected, routes_info)
+
+
+class TestCiscoCsrIPsecDeviceDriverSyncStatuses(base.BaseTestCase):
+
+    """Test status/state of services and connections, after sync."""
+
+    def setUp(self):
+        super(TestCiscoCsrIPsecDeviceDriverSyncStatuses, self).setUp()
+        self.addCleanup(mock.patch.stopall)
+        for klass in ['neutron.openstack.common.rpc.create_connection',
+                      'neutron.context.get_admin_context_without_session',
+                      'neutron.openstack.common.'
+                      'loopingcall.FixedIntervalLoopingCall']:
+            mock.patch(klass).start()
+        self.context = context.Context('some_user', 'some_tenant')
+        self.agent = mock.Mock()
+        conf_patch = mock.patch('oslo.config.cfg.CONF').start()
+        conf_patch.config_file = ['dummy']
+        self.config_load = mock.patch(FIND_CFG_FOR_CSRS).start()
+        self.config_load.return_value = {'1.1.1.1': {'rest_mgmt': '2.2.2.2',
+                                                     'tunnel_ip': '1.1.1.3',
+                                                     'username': 'pe',
+                                                     'password': 'password',
+                                                     'timeout': 120}}
+        self.driver = ipsec_driver.CiscoCsrIPsecDriver(self.agent, FAKE_HOST)
+        self.driver.agent_rpc = mock.Mock()
+        self.conn_create = mock.patch.object(
+            ipsec_driver.CiscoCsrIPSecConnection,
+            'create_ipsec_site_connection').start()
+        self.conn_delete = mock.patch.object(
+            ipsec_driver.CiscoCsrIPSecConnection,
+            'delete_ipsec_site_connection').start()
+        self.csr = mock.Mock()
+        self.driver.csrs['1.1.1.1'] = self.csr
+        self.service123_data = {u'id': u'123',
+                                u'status': constants.DOWN,
+                                u'admin_state_up': False,
+                                u'external_ip': u'1.1.1.1'}
+        self.conn1_data = {u'id': u'1', u'status': constants.ACTIVE,
+                           u'admin_state_up': True,
+                           u'cisco': {u'site_conn_id': u'Tunnel0'}}
+
+    # NOTE: For sync, there is mark (trivial), update (tested),
+    # sweep (tested), and report(tested) phases.
+
+    def test_update_ipsec_connection_create_notify(self):
+        """Notified of connection create request - create."""
+        # Make the (existing) service
+        self.driver.create_vpn_service(self.service123_data)
+        conn_data = {u'id': u'1', u'status': constants.PENDING_CREATE,
+                     u'admin_state_up': True,
+                     u'cisco': {u'site_conn_id': u'Tunnel0'}}
+
+        connection = self.driver.update_connection(self.context,
+                                                   u'123', conn_data)
+        self.assertFalse(connection.is_dirty)
+        self.assertEqual(u'Tunnel0', connection.tunnel)
+        self.assertEqual(constants.PENDING_CREATE, connection.last_status)
+        self.assertEqual(1, self.conn_create.call_count)
+
+    def test_update_ipsec_connection_changed_settings(self):
+        """Notified of connection changing config - update."""
+        # TODO(pcm) Place holder for this condition
+        # Make the (existing) service and connection
+        vpn_service = self.driver.create_vpn_service(self.service123_data)
+        # TODO(pcm) add info that indicates that the connection has changed
+        conn_data = {u'id': u'1', u'status': constants.ACTIVE,
+                     u'admin_state_up': True,
+                     u'cisco': {u'site_conn_id': u'Tunnel0'}}
+        vpn_service.create_connection(conn_data)
+        self.driver.mark_existing_connections_as_dirty()
+
+        connection = self.driver.update_connection(self.context,
+                                                   '123', conn_data)
+        self.assertFalse(connection.is_dirty)
+        self.assertEqual(u'Tunnel0', connection.tunnel)
+        self.assertEqual(constants.ACTIVE, connection.last_status)
+        self.assertEqual(0, self.conn_create.call_count)
+        # TODO(pcm) FUTURE - handling for update (delete/create?)
+
+    def test_update_of_unknown_ipsec_connection(self):
+        """Notified of update of unknown connection - create.
+
+        Occurs if agent restarts and receives a notification of change
+        to connection, but has no previous record of the connection.
+        Result will be to rebuild the connection.
+
+        This can also happen, if a connection is changed from admin
+        down to admin up (so don't need a separate test for admin up.
+        """
+        # Will have previously created service, but don't know of connection
+        self.driver.create_vpn_service(self.service123_data)
+        conn_data = {u'id': u'1', u'status': constants.DOWN,
+                     u'admin_state_up': True,
+                     u'cisco': {u'site_conn_id': u'Tunnel0'}}
+
+        connection = self.driver.update_connection(self.context,
+                                                   u'123', conn_data)
+        self.assertFalse(connection.is_dirty)
+        self.assertEqual(u'Tunnel0', connection.tunnel)
+        self.assertEqual(constants.DOWN, connection.last_status)
+        self.assertEqual(1, self.conn_create.call_count)
+
+    def test_update_unchanged_ipsec_connection(self):
+        """Unchanged state for connection during sync - nop."""
+        # Make the (existing) service and connection
+        vpn_service = self.driver.create_vpn_service(self.service123_data)
+        conn_data = {u'id': u'1', u'status': constants.ACTIVE,
+                     u'admin_state_up': True,
+                     u'cisco': {u'site_conn_id': u'Tunnel0'}}
+        vpn_service.create_connection(conn_data)
+        self.driver.mark_existing_connections_as_dirty()
+        # The notification (state) hasn't changed for the connection
+
+        connection = self.driver.update_connection(self.context,
+                                                   '123', conn_data)
+        self.assertFalse(connection.is_dirty)
+        self.assertEqual(u'Tunnel0', connection.tunnel)
+        self.assertEqual(constants.ACTIVE, connection.last_status)
+        self.assertEqual(0, self.conn_create.call_count)
+
+    def test_update_connection_admin_down(self):
+        """Connection updated to admin down state - dirty."""
+        # Make existing service, and conenction that was active
+        vpn_service = self.driver.create_vpn_service(self.service123_data)
+        conn_data = {u'id': '1', u'status': constants.ACTIVE,
+                     u'admin_state_up': True,
+                     u'cisco': {u'site_conn_id': u'Tunnel0'}}
+        vpn_service.create_connection(conn_data)
+        self.driver.mark_existing_connections_as_dirty()
+        # Now simulate that the notification shows the connection admin down
+        conn_data[u'admin_state_up'] = False
+        conn_data[u'status'] = constants.DOWN
+
+        connection = self.driver.update_connection(self.context,
+                                                   u'123', conn_data)
+        self.assertTrue(connection.is_dirty)
+        self.assertEqual(u'Tunnel0', connection.tunnel)
+        self.assertEqual(constants.DOWN, connection.last_status)
+        self.assertEqual(0, self.conn_create.call_count)
+
+    def test_update_missing_connection_admin_down(self):
+        """Connection not present is in admin down state - nop.
+
+        If the agent has restarted, and a sync notification occurs with
+        a connection that is in admin down state, ignore the connection
+        versus creating and marking dirty and then deleting.
+        """
+        # Make existing service, but no connection
+        self.driver.create_vpn_service(self.service123_data)
+        conn_data = {u'id': '1', u'status': constants.DOWN,
+                     u'admin_state_up': False,
+                     u'cisco': {u'site_conn_id': u'Tunnel0'}}
+
+        connection = self.driver.update_connection(self.context,
+                                                   u'123', conn_data)
+        self.assertIsNone(connection)
+        self.assertEqual(0, self.conn_create.call_count)
+
+    def test_update_for_vpn_service_create(self):
+        """Creation of new IPSec connection on new VPN service - create.
+
+        Service will be created and marked as 'clean', and update
+        processing for connection will occur (create).
+        """
+        conn_data = {u'id': u'1', u'status': constants.PENDING_CREATE,
+                     u'admin_state_up': True,
+                     u'cisco': {u'site_conn_id': u'Tunnel0'}}
+        service_data = {u'id': u'123',
+                        u'status': constants.PENDING_CREATE,
+                        u'external_ip': u'1.1.1.1',
+                        u'admin_state_up': True,
+                        u'ipsec_conns': [conn_data]}
+        vpn_service = self.driver.update_service(self.context, service_data)
+        self.assertFalse(vpn_service.is_dirty)
+        self.assertEqual(constants.PENDING_CREATE, vpn_service.last_status)
+        connection = vpn_service.get_connection(u'1')
+        self.assertIsNotNone(connection)
+        self.assertFalse(connection.is_dirty)
+        self.assertEqual(u'Tunnel0', connection.tunnel)
+        self.assertEqual(constants.PENDING_CREATE, connection.last_status)
+        self.assertEqual(1, self.conn_create.call_count)
+
+    def test_update_for_new_connection_on_existing_service(self):
+        """Creating a new IPSec connection on an existing service."""
+        # Create the service before testing, and mark it dirty
+        prev_vpn_service = self.driver.create_vpn_service(self.service123_data)
+        self.driver.mark_existing_connections_as_dirty()
+        conn_data = {u'id': u'1', u'status': constants.PENDING_CREATE,
+                     u'admin_state_up': True,
+                     u'cisco': {u'site_conn_id': u'Tunnel0'}}
+        service_data = {u'id': u'123',
+                        u'status': constants.ACTIVE,
+                        u'external_ip': u'1.1.1.1',
+                        u'admin_state_up': True,
+                        u'ipsec_conns': [conn_data]}
+        vpn_service = self.driver.update_service(self.context, service_data)
+        # Should reuse the entry and update the status
+        self.assertEqual(prev_vpn_service, vpn_service)
+        self.assertFalse(vpn_service.is_dirty)
+        self.assertEqual(constants.ACTIVE, vpn_service.last_status)
+        connection = vpn_service.get_connection(u'1')
+        self.assertIsNotNone(connection)
+        self.assertFalse(connection.is_dirty)
+        self.assertEqual(u'Tunnel0', connection.tunnel)
+        self.assertEqual(constants.PENDING_CREATE, connection.last_status)
+        self.assertEqual(1, self.conn_create.call_count)
+
+    def test_update_for_vpn_service_with_one_unchanged_connection(self):
+        """Existing VPN service and IPSec connection without any changes - nop.
+
+        Service and connection will be marked clean. No processing for
+        either, as there are no changes.
+        """
+        # Create a service and add in a connection that is active
+        prev_vpn_service = self.driver.create_vpn_service(self.service123_data)
+        conn_data = {u'id': u'1', u'status': constants.ACTIVE,
+                     u'admin_state_up': True,
+                     u'cisco': {u'site_conn_id': u'Tunnel0'}}
+        prev_vpn_service.create_connection(conn_data)
+        self.driver.mark_existing_connections_as_dirty()
+        # Create notification with conn unchanged and service already created
+        service_data = {u'id': u'123',
+                        u'status': constants.ACTIVE,
+                        u'external_ip': u'1.1.1.1',
+                        u'admin_state_up': True,
+                        u'ipsec_conns': [conn_data]}
+        vpn_service = self.driver.update_service(self.context, service_data)
+        # Should reuse the entry and update the status
+        self.assertEqual(prev_vpn_service, vpn_service)
+        self.assertFalse(vpn_service.is_dirty)
+        self.assertEqual(constants.ACTIVE, vpn_service.last_status)
+        connection = vpn_service.get_connection(u'1')
+        self.assertIsNotNone(connection)
+        self.assertFalse(connection.is_dirty)
+        self.assertEqual(u'Tunnel0', connection.tunnel)
+        self.assertEqual(constants.ACTIVE, connection.last_status)
+        self.assertEqual(0, self.conn_create.call_count)
+
+    def test_update_service_admin_down(self):
+        """VPN service updated to admin down state - dirty.
+
+        Mark service dirty and do not process any notfications for
+        connections using the service.
+        """
+        # Create an "existing" service, prior to notification
+        prev_vpn_service = self.driver.create_vpn_service(self.service123_data)
+        self.driver.mark_existing_connections_as_dirty()
+        conn_data = {u'id': u'1', u'status': constants.ACTIVE,
+                     u'admin_state_up': True,
+                     u'cisco': {u'site_conn_id': u'Tunnel0'}}
+        service_data = {u'id': u'123',
+                        u'status': constants.DOWN,
+                        u'external_ip': u'1.1.1.1',
+                        u'admin_state_up': False,
+                        u'ipsec_conns': [conn_data]}
+        vpn_service = self.driver.update_service(self.context, service_data)
+        self.assertEqual(prev_vpn_service, vpn_service)
+        self.assertTrue(vpn_service.is_dirty)
+        self.assertEqual(constants.DOWN, vpn_service.last_status)
+        self.assertIsNone(vpn_service.get_connection(u'1'))
+
+    def test_update_unknown_service_admin_down(self):
+        """Unknown VPN service uodated to admin down state - nop.
+
+        Can happen if agent restarts and then gets its first notificaiton
+        of a service that is in the admin down state. Will not do anything,
+        versus creating, marking dirty, and then deleting the VPN service.
+        """
+        service_data = {u'id': u'123',
+                        u'status': constants.DOWN,
+                        u'external_ip': u'1.1.1.1',
+                        u'admin_state_up': False,
+                        u'ipsec_conns': []}
+        vpn_service = self.driver.update_service(self.context, service_data)
+        self.assertIsNone(vpn_service)
+
+    def test_update_of_unknown_service_create(self):
+        """Create of VPN service that is currently unknown - record.
+
+        If agent is restarted or user changes VPN service to admin up, the
+        notification may contain a VPN service with an IPSec connection
+        that is not in PENDING_CREATE state.
+        """
+        conn_data = {u'id': u'1', u'status': constants.DOWN,
+                     u'admin_state_up': True,
+                     u'cisco': {u'site_conn_id': u'Tunnel0'}}
+        service_data = {u'id': u'123',
+                        u'status': constants.ACTIVE,
+                        u'external_ip': u'1.1.1.1',
+                        u'admin_state_up': True,
+                        u'ipsec_conns': [conn_data]}
+        vpn_service = self.driver.update_service(self.context, service_data)
+        self.assertFalse(vpn_service.is_dirty)
+        self.assertEqual(constants.ACTIVE, vpn_service.last_status)
+        connection = vpn_service.get_connection(u'1')
+        self.assertIsNotNone(connection)
+        self.assertFalse(connection.is_dirty)
+        self.assertEqual(u'Tunnel0', connection.tunnel)
+        self.assertEqual(constants.DOWN, connection.last_status)
+        self.assertEqual(1, self.conn_create.call_count)
+
+    def test_update_service_create_no_csr(self):
+        """Failure test of sync of service that is not on CSR - ignore.
+
+        Ignore the VPN service and its IPSec connection(s) notifications for
+        which there is no corresponding Cisco CSR.
+        """
+        conn_data = {u'id': u'1', u'status': constants.PENDING_CREATE,
+                     u'admin_state_up': True,
+                     u'cisco': {u'site_conn_id': u'Tunnel0'}}
+        service_data = {u'id': u'123',
+                        u'status': constants.PENDING_CREATE,
+                        u'external_ip': u'2.2.2.2',
+                        u'admin_state_up': True,
+                        u'ipsec_conns': [conn_data]}
+        vpn_service = self.driver.update_service(self.context, service_data)
+        self.assertIsNone(vpn_service)
+
+    def _check_connection_for_service(self, count, vpn_service):
+        """Helper to check the connection information for a service."""
+        connection = vpn_service.get_connection(u'%d' % count)
+        self.assertIsNotNone(connection, "for connection %d" % count)
+        self.assertFalse(connection.is_dirty, "for connection %d" % count)
+        self.assertEqual(u'Tunnel%d' % count, connection.tunnel,
+                         "for connection %d" % count)
+        self.assertEqual(constants.PENDING_CREATE, connection.last_status,
+                         "for connection %d" % count)
+        return count + 1
+
+    def notification_for_two_services_with_two_conns(self):
+        """Helper used by tests to create two services, each with two conns."""
+        conn1_data = {u'id': u'1', u'status': constants.PENDING_CREATE,
+                      u'admin_state_up': True,
+                      u'cisco': {u'site_conn_id': u'Tunnel1'}}
+        conn2_data = {u'id': u'2', u'status': constants.PENDING_CREATE,
+                      u'admin_state_up': True,
+                      u'cisco': {u'site_conn_id': u'Tunnel2'}}
+        service1_data = {u'id': u'123',
+                         u'status': constants.PENDING_CREATE,
+                         u'external_ip': u'1.1.1.1',
+                         u'admin_state_up': True,
+                         u'ipsec_conns': [conn1_data, conn2_data]}
+        conn3_data = {u'id': u'3', u'status': constants.PENDING_CREATE,
+                      u'admin_state_up': True,
+                      u'cisco': {u'site_conn_id': u'Tunnel3'}}
+        conn4_data = {u'id': u'4', u'status': constants.PENDING_CREATE,
+                      u'admin_state_up': True,
+                      u'cisco': {u'site_conn_id': u'Tunnel4'}}
+        service2_data = {u'id': u'456',
+                         u'status': constants.PENDING_CREATE,
+                         u'external_ip': u'1.1.1.1',
+                         u'admin_state_up': True,
+                         u'ipsec_conns': [conn3_data, conn4_data]}
+        return service1_data, service2_data
+
+    def test_create_two_connections_on_two_services(self):
+        """High level test of multiple VPN services with connections."""
+        # Build notification message
+        (service1_data,
+         service2_data) = self.notification_for_two_services_with_two_conns()
+        # Simulate plugin returning notifcation, when requested
+        self.driver.agent_rpc.get_vpn_services_on_host.return_value = [
+            service1_data, service2_data]
+        vpn_services = self.driver.update_all_services_and_connections(
+            self.context)
+        self.assertEqual(2, len(vpn_services))
+        count = 1
+        for vpn_service in vpn_services:
+            self.assertFalse(vpn_service.is_dirty,
+                             "for service %s" % vpn_service)
+            self.assertEqual(constants.PENDING_CREATE, vpn_service.last_status,
+                             "for service %s" % vpn_service)
+            count = self._check_connection_for_service(count, vpn_service)
+            count = self._check_connection_for_service(count, vpn_service)
+        self.assertEqual(4, self.conn_create.call_count)
+
+    def test_sweep_connection_marked_as_clean(self):
+        """Sync updated connection - no action."""
+        # Create a service and connection
+        vpn_service = self.driver.create_vpn_service(self.service123_data)
+        connection = vpn_service.create_connection(self.conn1_data)
+        self.driver.mark_existing_connections_as_dirty()
+        # Simulate that the update phase visted both of them
+        vpn_service.is_dirty = False
+        connection.is_dirty = False
+        self.driver.remove_unknown_connections(self.context)
+        vpn_service = self.driver.service_state.get(u'123')
+        self.assertIsNotNone(vpn_service)
+        self.assertFalse(vpn_service.is_dirty)
+        connection = vpn_service.get_connection(u'1')
+        self.assertIsNotNone(connection)
+        self.assertFalse(connection.is_dirty)
+
+    def test_sweep_connection_dirty(self):
+        """Sync did not update connection - delete."""
+        # Create a service and connection
+        vpn_service = self.driver.create_vpn_service(self.service123_data)
+        vpn_service.create_connection(self.conn1_data)
+        self.driver.mark_existing_connections_as_dirty()
+        # Simulate that the update phase only visited the service
+        vpn_service.is_dirty = False
+        self.driver.remove_unknown_connections(self.context)
+        vpn_service = self.driver.service_state.get(u'123')
+        self.assertIsNotNone(vpn_service)
+        self.assertFalse(vpn_service.is_dirty)
+        connection = vpn_service.get_connection(u'1')
+        self.assertIsNone(connection)
+        self.assertEqual(1, self.conn_delete.call_count)
+
+    def test_sweep_service_dirty(self):
+        """Sync did not update service - delete it and all conns."""
+        # Create a service and connection
+        vpn_service = self.driver.create_vpn_service(self.service123_data)
+        vpn_service.create_connection(self.conn1_data)
+        self.driver.mark_existing_connections_as_dirty()
+        # Both the service and the connection are still 'dirty'
+        self.driver.remove_unknown_connections(self.context)
+        self.assertIsNone(self.driver.service_state.get(u'123'))
+        self.assertEqual(1, self.conn_delete.call_count)
+
+    def test_sweep_multiple_services(self):
+        """One service and conn udpated, one service and conn not."""
+        # Create two services, each with a connection
+        vpn_service1 = self.driver.create_vpn_service(self.service123_data)
+        vpn_service1.create_connection(self.conn1_data)
+        service456_data = {u'id': u'456',
+                           u'status': constants.ACTIVE,
+                           u'admin_state_up': False,
+                           u'external_ip': u'1.1.1.1'}
+        conn2_data = {u'id': u'2', u'status': constants.ACTIVE,
+                      u'admin_state_up': True,
+                      u'cisco': {u'site_conn_id': u'Tunnel0'}}
+        prev_vpn_service2 = self.driver.create_vpn_service(service456_data)
+        prev_connection2 = prev_vpn_service2.create_connection(conn2_data)
+        self.driver.mark_existing_connections_as_dirty()
+        # Simulate that the update phase visited the first service and conn
+        prev_vpn_service2.is_dirty = False
+        prev_connection2.is_dirty = False
+        self.driver.remove_unknown_connections(self.context)
+        self.assertIsNone(self.driver.service_state.get(u'123'))
+        vpn_service2 = self.driver.service_state.get(u'456')
+        self.assertEqual(prev_vpn_service2, vpn_service2)
+        self.assertFalse(vpn_service2.is_dirty)
+        connection2 = vpn_service2.get_connection(u'2')
+        self.assertEqual(prev_connection2, connection2)
+        self.assertFalse(connection2.is_dirty)
+        self.assertEqual(1, self.conn_delete.call_count)
+
+    def simulate_mark_update_sweep_for_service_with_conn(self, service_state,
+                                                         connection_state):
+        """Create internal structures for single service with connection."""
+        # Simulate that we have done mark, update, and sweep.
+        conn_data = {u'id': u'1', u'status': connection_state,
+                     u'admin_state_up': True,
+                     u'cisco': {u'site_conn_id': u'Tunnel0'}}
+        service_data = {u'id': u'123',
+                        u'status': service_state,
+                        u'external_ip': u'1.1.1.1',
+                        u'admin_state_up': True,
+                        u'ipsec_conns': [conn_data]}
+        return self.driver.update_service(self.context, service_data)
+
+    def test_report_fragment_connection_created(self):
+        """Generate report section for a created connection."""
+        # Prepare service and connection in PENDING_CREATE state
+        vpn_service = self.simulate_mark_update_sweep_for_service_with_conn(
+            constants.PENDING_CREATE, constants.PENDING_CREATE)
+        # Simulate that CSR has reported the connection is still up
+        self.csr.read_tunnel_statuses.return_value = [
+            (u'Tunnel0', u'UP-ACTIVE'), ]
+
+        # Get the statuses for connections existing on CSR
+        tunnels = vpn_service.get_ipsec_connections_status()
+        self.assertEqual({u'Tunnel0': constants.ACTIVE}, tunnels)
+
+        # Check that there is a status for this connection
+        connection = vpn_service.get_connection(u'1')
+        self.assertIsNotNone(connection)
+        current_status = connection.find_current_status_in(tunnels)
+        self.assertEqual(constants.ACTIVE, current_status)
+
+        # Create report fragment due to change
+        self.assertNotEqual(connection.last_status, current_status)
+        report_frag = connection.build_report_based_on_status(current_status)
+        self.assertEqual(current_status, connection.last_status)
+        expected = {'1': {'status': constants.ACTIVE,
+                    'updated_pending_status': True}}
+        self.assertEqual(expected, report_frag)
+
+    def test_report_fragment_connection_unchanged_status(self):
+        """No report section generated for a created connection."""
+        # Prepare service and connection in ACTIVE state
+        vpn_service = self.simulate_mark_update_sweep_for_service_with_conn(
+            constants.ACTIVE, constants.ACTIVE)
+        # Simulate that CSR has reported the connection is up
+        self.csr.read_tunnel_statuses.return_value = [
+            (u'Tunnel0', u'UP-IDLE'), ]
+
+        # Get the statuses for connections existing on CSR
+        tunnels = vpn_service.get_ipsec_connections_status()
+        self.assertEqual({u'Tunnel0': constants.ACTIVE}, tunnels)
+
+        # Check that there is a status for this connection
+        connection = vpn_service.get_connection(u'1')
+        self.assertIsNotNone(connection)
+        current_status = connection.find_current_status_in(tunnels)
+        self.assertEqual(constants.ACTIVE, current_status)
+
+        # Should be no report, as no change
+        self.assertEqual(connection.last_status, current_status)
+        report_frag = connection.build_report_based_on_status(current_status)
+        self.assertEqual(current_status, connection.last_status)
+        self.assertEqual({}, report_frag)
+
+    def test_report_fragment_connection_changed_status(self):
+        """Generate report section for connection with changed state."""
+        # Prepare service in ACTIVE state and connection in DOWN state
+        vpn_service = self.simulate_mark_update_sweep_for_service_with_conn(
+            constants.ACTIVE, constants.DOWN)
+        # Simulate that CSR has reported the connection is still up
+        self.csr.read_tunnel_statuses.return_value = [
+            (u'Tunnel0', u'UP-NO-IKE'), ]
+
+        # Get the statuses for connections existing on CSR
+        tunnels = vpn_service.get_ipsec_connections_status()
+        self.assertEqual({u'Tunnel0': constants.ACTIVE}, tunnels)
+
+        # Check that there is a status for this connection
+        connection = vpn_service.get_connection(u'1')
+        self.assertIsNotNone(connection)
+        current_status = connection.find_current_status_in(tunnels)
+        self.assertEqual(constants.ACTIVE, current_status)
+
+        # Create report fragment due to change
+        self.assertNotEqual(connection.last_status, current_status)
+        report_frag = connection.build_report_based_on_status(current_status)
+        self.assertEqual(current_status, connection.last_status)
+        expected = {'1': {'status': constants.ACTIVE,
+                    'updated_pending_status': False}}
+        self.assertEqual(expected, report_frag)
+
+    def test_report_fragment_connection_failed_create(self):
+        """Failure test of report fragment for conn that failed creation.
+
+        Normally, without any status from the CSR, the connection report would
+        be skipped, but we need to report back failures.
+        """
+        # Prepare service and connection in PENDING_CREATE state
+        vpn_service = self.simulate_mark_update_sweep_for_service_with_conn(
+            constants.PENDING_CREATE, constants.PENDING_CREATE)
+        # Simulate that CSR does NOT report the status (no tunnel)
+        self.csr.read_tunnel_statuses.return_value = []
+
+        # Get the statuses for connections existing on CSR
+        tunnels = vpn_service.get_ipsec_connections_status()
+        self.assertEqual({}, tunnels)
+
+        # Check that there is a status for this connection
+        connection = vpn_service.get_connection(u'1')
+        self.assertIsNotNone(connection)
+        current_status = connection.find_current_status_in(tunnels)
+        self.assertEqual(constants.ERROR, current_status)
+
+        # Create report fragment due to change
+        self.assertNotEqual(connection.last_status, current_status)
+        report_frag = connection.build_report_based_on_status(current_status)
+        self.assertEqual(current_status, connection.last_status)
+        expected = {'1': {'status': constants.ERROR,
+                    'updated_pending_status': True}}
+        self.assertEqual(expected, report_frag)
+
+    def test_report_fragment_two_connections(self):
+        """Generate report fragment for two connections on a service."""
+        # Prepare service with two connections, one ACTIVE, one DOWN
+        conn1_data = {u'id': u'1', u'status': constants.DOWN,
+                      u'admin_state_up': True,
+                      u'cisco': {u'site_conn_id': u'Tunnel1'}}
+        conn2_data = {u'id': u'2', u'status': constants.ACTIVE,
+                      u'admin_state_up': True,
+                      u'cisco': {u'site_conn_id': u'Tunnel2'}}
+        service_data = {u'id': u'123',
+                        u'status': constants.ACTIVE,
+                        u'external_ip': u'1.1.1.1',
+                        u'admin_state_up': True,
+                        u'ipsec_conns': [conn1_data, conn2_data]}
+        vpn_service = self.driver.update_service(self.context, service_data)
+        # Simulate that CSR has reported the connections with diff status
+        self.csr.read_tunnel_statuses.return_value = [
+            (u'Tunnel1', u'UP-IDLE'), (u'Tunnel2', u'DOWN-NEGOTIATING')]
+
+        # Get the report fragments for the connections
+        report_frag = self.driver.build_report_for_connections_on(vpn_service)
+        expected = {u'1': {u'status': constants.ACTIVE,
+                           u'updated_pending_status': False},
+                    u'2': {u'status': constants.DOWN,
+                           u'updated_pending_status': False}}
+        self.assertEqual(expected, report_frag)
+
+    def test_report_service_create(self):
+        """VPN service and IPSec connection created - report."""
+        # Simulate creation of the service and connection
+        vpn_service = self.simulate_mark_update_sweep_for_service_with_conn(
+            constants.PENDING_CREATE, constants.PENDING_CREATE)
+        # Simulate that the CSR has created the connection
+        self.csr.read_tunnel_statuses.return_value = [
+            (u'Tunnel0', u'UP-ACTIVE'), ]
+
+        report = self.driver.build_report_for_service(vpn_service)
+        expected_report = {
+            u'id': u'123',
+            u'updated_pending_status': True,
+            u'status': constants.ACTIVE,
+            u'ipsec_site_connections': {
+                u'1': {u'status': constants.ACTIVE,
+                       u'updated_pending_status': True}
+            }
+        }
+        self.assertEqual(expected_report, report)
+        # Check that service and connection statuses are updated
+        self.assertEqual(constants.ACTIVE, vpn_service.last_status)
+        self.assertEqual(constants.ACTIVE,
+                         vpn_service.get_connection(u'1').last_status)
+
+    def test_report_service_create_of_first_conn_fails(self):
+        """VPN service and IPSec conn created, but conn failed - report.
+
+        Since this is the sole IPSec connection on the service, and the
+        create failed (connection in ERROR state), the VPN service's
+        status will be set to DOWN.
+        """
+        # Simulate creation of the service and connection
+        vpn_service = self.simulate_mark_update_sweep_for_service_with_conn(
+            constants.PENDING_CREATE, constants.PENDING_CREATE)
+        # Simulate that the CSR has no info due to failed create
+        self.csr.read_tunnel_statuses.return_value = []
+
+        report = self.driver.build_report_for_service(vpn_service)
+        expected_report = {
+            u'id': u'123',
+            u'updated_pending_status': True,
+            u'status': constants.DOWN,
+            u'ipsec_site_connections': {
+                u'1': {u'status': constants.ERROR,
+                       u'updated_pending_status': True}
+            }
+        }
+        self.assertEqual(expected_report, report)
+        # Check that service and connection statuses are updated
+        self.assertEqual(constants.DOWN, vpn_service.last_status)
+        self.assertEqual(constants.ERROR,
+                         vpn_service.get_connection(u'1').last_status)
+
+    def test_report_connection_created_on_existing_service(self):
+        """Creating connection on existing service - report."""
+        # Simulate existing service and connection create
+        vpn_service = self.simulate_mark_update_sweep_for_service_with_conn(
+            constants.ACTIVE, constants.PENDING_CREATE)
+        # Simulate that the CSR has created the connection
+        self.csr.read_tunnel_statuses.return_value = [
+            (u'Tunnel0', u'UP-IDLE'), ]
+
+        report = self.driver.build_report_for_service(vpn_service)
+        expected_report = {
+            u'id': u'123',
+            u'updated_pending_status': False,
+            u'status': constants.ACTIVE,
+            u'ipsec_site_connections': {
+                u'1': {u'status': constants.ACTIVE,
+                       u'updated_pending_status': True}
+            }
+        }
+        self.assertEqual(expected_report, report)
+        # Check that service and connection statuses are updated
+        self.assertEqual(constants.ACTIVE, vpn_service.last_status)
+        self.assertEqual(constants.ACTIVE,
+                         vpn_service.get_connection(u'1').last_status)
+
+    def test_no_report_no_changes(self):
+        """VPN service with unchanged IPSec connection - no report.
+
+        Note: No report will be generated if the last connection on the
+        service is deleted. The service (and connection) objects will
+        have been reoved by the sweep operation and thus not reported.
+        On the plugin, the service should be changed to DOWN. Likewise,
+        if the service goes to admin down state.
+        """
+        # Simulate an existing service and connection that are ACTIVE
+        vpn_service = self.simulate_mark_update_sweep_for_service_with_conn(
+            constants.ACTIVE, constants.ACTIVE)
+        # Simulate that the CSR reports the connection still active
+        self.csr.read_tunnel_statuses.return_value = [
+            (u'Tunnel0', u'UP-ACTIVE'), ]
+
+        report = self.driver.build_report_for_service(vpn_service)
+        self.assertEqual({}, report)
+        # Check that service and connection statuses are still same
+        self.assertEqual(constants.ACTIVE, vpn_service.last_status)
+        self.assertEqual(constants.ACTIVE,
+                         vpn_service.get_connection(u'1').last_status)
+
+    def test_report_sole_connection_goes_down(self):
+        """Only connection on VPN service goes down - report.
+
+        In addition to reporting the status change and recording the new
+        state for the IPSec connection, the VPN service status will be
+        ACTIVE.
+        """
+        # Simulate an existing service and connection that are ACTIVE
+        vpn_service = self.simulate_mark_update_sweep_for_service_with_conn(
+            constants.ACTIVE, constants.ACTIVE)
+        # Simulate that the CSR reports the connection went down
+        self.csr.read_tunnel_statuses.return_value = [
+            (u'Tunnel0', u'DOWN-NEGOTIATING'), ]
+
+        report = self.driver.build_report_for_service(vpn_service)
+        expected_report = {
+            u'id': u'123',
+            u'updated_pending_status': False,
+            u'status': constants.DOWN,
+            u'ipsec_site_connections': {
+                u'1': {u'status': constants.DOWN,
+                       u'updated_pending_status': False}
+            }
+        }
+        self.assertEqual(expected_report, report)
+        # Check that service and connection statuses are updated
+        self.assertEqual(constants.DOWN, vpn_service.last_status)
+        self.assertEqual(constants.DOWN,
+                         vpn_service.get_connection(u'1').last_status)
+
+    def test_report_sole_connection_comes_up(self):
+        """Only connection on VPN service comes up - report.
+
+        In addition to reporting the status change and recording the new
+        state for the IPSec connection, the VPN service status will be
+        DOWN.
+        """
+        # Simulate an existing service and connection that are DOWN
+        vpn_service = self.simulate_mark_update_sweep_for_service_with_conn(
+            constants.DOWN, constants.DOWN)
+        # Simulate that the CSR reports the connection came up
+        self.csr.read_tunnel_statuses.return_value = [
+            (u'Tunnel0', u'UP-NO-IKE'), ]
+
+        report = self.driver.build_report_for_service(vpn_service)
+        expected_report = {
+            u'id': u'123',
+            u'updated_pending_status': False,
+            u'status': constants.ACTIVE,
+            u'ipsec_site_connections': {
+                u'1': {u'status': constants.ACTIVE,
+                       u'updated_pending_status': False}
+            }
+        }
+        self.assertEqual(expected_report, report)
+        # Check that service and connection statuses are updated
+        self.assertEqual(constants.ACTIVE, vpn_service.last_status)
+        self.assertEqual(constants.ACTIVE,
+                         vpn_service.get_connection(u'1').last_status)
+
+    def test_report_service_with_two_connections_gone_down(self):
+        """One service with two connections that went down - report.
+
+        If there is more than one IPSec connection on a VPN service, the
+        service will always report as being ACTIVE (whereas, if there is
+        only one connection, the service will reflect the connection status.
+        """
+        # Simulated one service with two ACTIVE connections
+        conn1_data = {u'id': u'1', u'status': constants.ACTIVE,
+                      u'admin_state_up': True,
+                      u'cisco': {u'site_conn_id': u'Tunnel1'}}
+        conn2_data = {u'id': u'2', u'status': constants.ACTIVE,
+                      u'admin_state_up': True,
+                      u'cisco': {u'site_conn_id': u'Tunnel2'}}
+        service_data = {u'id': u'123',
+                        u'status': constants.ACTIVE,
+                        u'external_ip': u'1.1.1.1',
+                        u'admin_state_up': True,
+                        u'ipsec_conns': [conn1_data, conn2_data]}
+        vpn_service = self.driver.update_service(self.context, service_data)
+        # Simulate that the CSR has reported that the connections are DOWN
+        self.csr.read_tunnel_statuses.return_value = [
+            (u'Tunnel1', u'DOWN-NEGOTIATING'), (u'Tunnel2', u'DOWN')]
+
+        report = self.driver.build_report_for_service(vpn_service)
+        expected_report = {
+            u'id': u'123',
+            u'updated_pending_status': False,
+            u'status': constants.ACTIVE,
+            u'ipsec_site_connections': {
+                u'1': {u'status': constants.DOWN,
+                       u'updated_pending_status': False},
+                u'2': {u'status': constants.DOWN,
+                       u'updated_pending_status': False}}
+        }
+        self.assertEqual(expected_report, report)
+        # Check that service and connection statuses are updated
+        self.assertEqual(constants.ACTIVE, vpn_service.last_status)
+        self.assertEqual(constants.DOWN,
+                         vpn_service.get_connection(u'1').last_status)
+        self.assertEqual(constants.DOWN,
+                         vpn_service.get_connection(u'2').last_status)
+
+    def test_report_multiple_services(self):
+        """Status changes for several services - report."""
+        # Simulate creation of the service and connection
+        (service1_data,
+         service2_data) = self.notification_for_two_services_with_two_conns()
+        vpn_service1 = self.driver.update_service(self.context, service1_data)
+        vpn_service2 = self.driver.update_service(self.context, service2_data)
+        # Simulate that the CSR has created the connections
+        self.csr.read_tunnel_statuses.return_value = [
+            (u'Tunnel1', u'UP-ACTIVE'), (u'Tunnel2', u'DOWN'),
+            (u'Tunnel3', u'DOWN-NEGOTIATING'), (u'Tunnel4', u'UP-IDLE')]
+
+        report = self.driver.report_status(self.context)
+        expected_report = [{u'id': u'123',
+                            u'updated_pending_status': True,
+                            u'status': constants.ACTIVE,
+                            u'ipsec_site_connections': {
+                                u'1': {u'status': constants.ACTIVE,
+                                       u'updated_pending_status': True},
+                            u'2': {u'status': constants.DOWN,
+                                   u'updated_pending_status': True}}
+                            },
+                           {u'id': u'456',
+                            u'updated_pending_status': True,
+                            u'status': constants.ACTIVE,
+                            u'ipsec_site_connections': {
+                                u'3': {u'status': constants.DOWN,
+                                       u'updated_pending_status': True},
+                                u'4': {u'status': constants.ACTIVE,
+                                       u'updated_pending_status': True}}
+                            }]
+        self.assertEqual(expected_report, report)
+        # Check that service and connection statuses are updated
+        self.assertEqual(constants.ACTIVE, vpn_service1.last_status)
+        self.assertEqual(constants.ACTIVE,
+                         vpn_service1.get_connection(u'1').last_status)
+        self.assertEqual(constants.DOWN,
+                         vpn_service1.get_connection(u'2').last_status)
+        self.assertEqual(constants.ACTIVE, vpn_service2.last_status)
+        self.assertEqual(constants.DOWN,
+                         vpn_service2.get_connection(u'3').last_status)
+        self.assertEqual(constants.ACTIVE,
+                         vpn_service2.get_connection(u'4').last_status)
+
+    # TODO(pcm) FUTURE - UTs for update action, when supported.
+
+    def test_vpnservice_updated(self):
+        with mock.patch.object(self.driver, 'sync') as sync:
+            context = mock.Mock()
+            self.driver.vpnservice_updated(context)
+            sync.assert_called_once_with(context, [])
+
+
+class TestCiscoCsrIPsecDeviceDriverConfigLoading(base.BaseTestCase):
+
+    def setUp(self):
+        super(TestCiscoCsrIPsecDeviceDriverConfigLoading, self).setUp()
+        self.addCleanup(mock.patch.stopall)
+
+    def create_tempfile(self, contents):
+        (fd, path) = tempfile.mkstemp(prefix='test', suffix='.conf')
+        try:
+            os.write(fd, contents.encode('utf-8'))
+        finally:
+            os.close(fd)
+        return path
+
+    def test_loading_csr_configuration(self):
+        """Ensure that Cisco CSR configs can be loaded from config files."""
+        cfg_file = self.create_tempfile('[CISCO_CSR_REST:3.2.1.1]\n'
+                                        'rest_mgmt = 10.20.30.1\n'
+                                        'tunnel_ip = 3.2.1.3\n'
+                                        'username = me\n'
+                                        'password = secret\n'
+                                        'timeout = 5.0\n')
+        expected = {'3.2.1.1': {'rest_mgmt': '10.20.30.1',
+                                'tunnel_ip': '3.2.1.3',
+                                'username': 'me',
+                                'password': 'secret',
+                                'timeout': 5.0}}
+        csrs_found = ipsec_driver.find_available_csrs_from_config([cfg_file])
+        self.assertEqual(expected, csrs_found)
+
+    def test_loading_config_without_timeout(self):
+        """Cisco CSR config without timeout will use default timeout."""
+        cfg_file = self.create_tempfile('[CISCO_CSR_REST:3.2.1.1]\n'
+                                        'rest_mgmt = 10.20.30.1\n'
+                                        'tunnel_ip = 3.2.1.3\n'
+                                        'username = me\n'
+                                        'password = secret\n')
+        expected = {'3.2.1.1': {'rest_mgmt': '10.20.30.1',
+                                'tunnel_ip': '3.2.1.3',
+                                'username': 'me',
+                                'password': 'secret',
+                                'timeout': csr_client.TIMEOUT}}
+        csrs_found = ipsec_driver.find_available_csrs_from_config([cfg_file])
+        self.assertEqual(expected, csrs_found)
+
+    def test_skip_loading_duplicate_csr_configuration(self):
+        """Failure test that duplicate configurations are ignored."""
+        cfg_file = self.create_tempfile('[CISCO_CSR_REST:3.2.1.1]\n'
+                                        'rest_mgmt = 10.20.30.1\n'
+                                        'tunnel_ip = 3.2.1.3\n'
+                                        'username = me\n'
+                                        'password = secret\n'
+                                        'timeout = 5.0\n'
+                                        '[CISCO_CSR_REST:3.2.1.1]\n'
+                                        'rest_mgmt = 5.5.5.3\n'
+                                        'tunnel_ip = 3.2.1.6\n'
+                                        'username = me\n'
+                                        'password = secret\n')
+        expected = {'3.2.1.1': {'rest_mgmt': '10.20.30.1',
+                                'tunnel_ip': '3.2.1.3',
+                                'username': 'me',
+                                'password': 'secret',
+                                'timeout': 5.0}}
+        csrs_found = ipsec_driver.find_available_csrs_from_config([cfg_file])
+        self.assertEqual(expected, csrs_found)
+
+    def test_fail_loading_config_with_invalid_timeout(self):
+        """Failure test of invalid timeout in config info."""
+        cfg_file = self.create_tempfile('[CISCO_CSR_REST:3.2.1.1]\n'
+                                        'rest_mgmt = 10.20.30.1\n'
+                                        'tunnel_ip = 3.2.1.3\n'
+                                        'username = me\n'
+                                        'password = secret\n'
+                                        'timeout = yes\n')
+        csrs_found = ipsec_driver.find_available_csrs_from_config([cfg_file])
+        self.assertEqual({}, csrs_found)
+
+    def test_fail_loading_config_missing_required_info(self):
+        """Failure test of config missing required info."""
+        cfg_file = self.create_tempfile('[CISCO_CSR_REST:1.1.1.0]\n'
+                                        'tunnel_ip = 1.1.1.3\n'
+                                        'username = me\n'
+                                        'password = secret\n'
+                                        'timeout = 5.0\n'
+                                        '[CISCO_CSR_REST:2.2.2.0]\n'
+                                        'rest_mgmt = 10.20.30.1\n'
+                                        'username = me\n'
+                                        'password = secret\n'
+                                        'timeout = 5.0\n'
+                                        '[CISCO_CSR_REST:3.3.3.0]\n'
+                                        'rest_mgmt = 10.20.30.1\n'
+                                        'tunnel_ip = 3.3.3.3\n'
+                                        'password = secret\n'
+                                        'timeout = 5.0\n'
+                                        '[CISCO_CSR_REST:4.4.4.0]\n'
+                                        'rest_mgmt = 10.20.30.1\n'
+                                        'tunnel_ip = 4.4.4.4\n'
+                                        'username = me\n'
+                                        'timeout = 5.0\n')
+        csrs_found = ipsec_driver.find_available_csrs_from_config([cfg_file])
+        self.assertEqual({}, csrs_found)
+
+    def test_fail_loading_config_with_invalid_router_id(self):
+        """Failure test of config with invalid rotuer ID."""
+        cfg_file = self.create_tempfile('[CISCO_CSR_REST:4.3.2.1.9]\n'
+                                        'rest_mgmt = 10.20.30.1\n'
+                                        'tunnel_ip = 4.3.2.3\n'
+                                        'username = me\n'
+                                        'password = secret\n'
+                                        'timeout = 5.0\n')
+        csrs_found = ipsec_driver.find_available_csrs_from_config([cfg_file])
+        self.assertEqual({}, csrs_found)
+
+    def test_fail_loading_config_with_invalid_mgmt_ip(self):
+        """Failure test of configuration with invalid management IP address."""
+        cfg_file = self.create_tempfile('[CISCO_CSR_REST:3.2.1.1]\n'
+                                        'rest_mgmt = 1.1.1.1.1\n'
+                                        'tunnel_ip = 3.2.1.3\n'
+                                        'username = me\n'
+                                        'password = secret\n'
+                                        'timeout = 5.0\n')
+        csrs_found = ipsec_driver.find_available_csrs_from_config([cfg_file])
+        self.assertEqual({}, csrs_found)
+
+    def test_fail_loading_config_with_invalid_tunnel_ip(self):
+        """Failure test of configuration with invalid tunnel IP address."""
+        cfg_file = self.create_tempfile('[CISCO_CSR_REST:3.2.1.1]\n'
+                                        'rest_mgmt = 1.1.1.1\n'
+                                        'tunnel_ip = 3.2.1.4.5\n'
+                                        'username = me\n'
+                                        'password = secret\n'
+                                        'timeout = 5.0\n')
+        csrs_found = ipsec_driver.find_available_csrs_from_config([cfg_file])
+        self.assertEqual({}, csrs_found)
+
+    def test_failure_no_configurations_entries(self):
+        """Failure test config file without any CSR definitions."""
+        cfg_file = self.create_tempfile('NO CISCO SECTION AT ALL\n')
+        csrs_found = ipsec_driver.find_available_csrs_from_config([cfg_file])
+        self.assertEqual({}, csrs_found)
+
+    def test_failure_no_csr_configurations_entries(self):
+        """Failure test config file without any CSR definitions."""
+        cfg_file = self.create_tempfile('[SOME_CONFIG:123]\n'
+                                        'username = me\n')
+        csrs_found = ipsec_driver.find_available_csrs_from_config([cfg_file])
+        self.assertEqual({}, csrs_found)
+
+    def test_missing_config_value(self):
+        """Failure test of config file missing a value for attribute."""
+        cfg_file = self.create_tempfile('[CISCO_CSR_REST:3.2.1.1]\n'
+                                        'rest_mgmt = \n'
+                                        'tunnel_ip = 3.2.1.3\n'
+                                        'username = me\n'
+                                        'password = secret\n'
+                                        'timeout = 5.0\n')
+        csrs_found = ipsec_driver.find_available_csrs_from_config([cfg_file])
+        self.assertEqual({}, csrs_found)
+
+    def test_ignores_invalid_attribute_in_config(self):
+        """Test ignoring of config file with invalid attribute."""
+        cfg_file = self.create_tempfile('[CISCO_CSR_REST:3.2.1.1]\n'
+                                        'rest_mgmt = 1.1.1.1\n'
+                                        'bogus = abcdef\n'
+                                        'tunnel_ip = 3.2.1.3\n'
+                                        'username = me\n'
+                                        'password = secret\n'
+                                        'timeout = 15.5\n')
+        expected = {'3.2.1.1': {'rest_mgmt': '1.1.1.1',
+                                'tunnel_ip': '3.2.1.3',
+                                'username': 'me',
+                                'password': 'secret',
+                                'timeout': 15.5}}
+        csrs_found = ipsec_driver.find_available_csrs_from_config([cfg_file])
+        self.assertEqual(expected, csrs_found)