SEBA-89 Finish sync steps, add unit tests

Change-Id: I39495e6167fceffa4c86ad5e6c4d238271ecbcc7
diff --git a/samples/bng_mapping.yaml b/samples/bng_mapping.yaml
new file mode 100644
index 0000000..f61bfdb
--- /dev/null
+++ b/samples/bng_mapping.yaml
@@ -0,0 +1,27 @@
+# Copyright 2017-present Open Networking Foundation
+#
+# 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.
+
+# curl -H "xos-username: admin@opencord.org" -H "xos-password: letmein" -X POST --data-binary @pon_port.yaml http://192.168.99.100:30007/run
+
+tosca_definitions_version: tosca_simple_yaml_1_0
+imports:
+  - custom_types/bngportmapping.yaml
+description: Create a bng port mapping
+topology_template:
+  node_templates:
+    bngmapping:
+      type: tosca.nodes.BNGPortMapping
+      properties:
+        s_tag: 222
+        switch_port: 4
diff --git a/xos/synchronizer/models/fabric-crossconnect.xproto b/xos/synchronizer/models/fabric-crossconnect.xproto
index 29b90e1..4a7e8dc 100644
--- a/xos/synchronizer/models/fabric-crossconnect.xproto
+++ b/xos/synchronizer/models/fabric-crossconnect.xproto
@@ -6,8 +6,12 @@
     option description="Fabric Crossconnect implementation";
 }
 
-
 message FabricCrossconnectServiceInstance (ServiceInstance){
     option verbose_name = "Fabric Crossconnect Service Instance";
     option owner_class_name="FabricCrossconnectService";
 }
+
+message BNGPortMapping (XOSBase) {
+    required int32 s_tag = 1 [help_text = "S Tag", null = False, db_index = False, blank = False, unique=True, tosca_key=True];
+    required int32 switch_port = 2 [help_text = "Port Number", null = False, db_index = False, blank = False];
+}
diff --git a/xos/synchronizer/steps/sync_fabric_crossconnect_service_instance.py b/xos/synchronizer/steps/sync_fabric_crossconnect_service_instance.py
index 8f03075..46a4694 100644
--- a/xos/synchronizer/steps/sync_fabric_crossconnect_service_instance.py
+++ b/xos/synchronizer/steps/sync_fabric_crossconnect_service_instance.py
@@ -13,7 +13,7 @@
 # limitations under the License.
 
 from synchronizers.new_base.syncstep import SyncStep, DeferredException
-from synchronizers.new_base.modelaccessor import model_accessor, FabricCrossconnectServiceInstance, ServiceInstance
+from synchronizers.new_base.modelaccessor import model_accessor, FabricCrossconnectServiceInstance, ServiceInstance, BNGPortMapping
 
 from xosconfig import Config
 from multistructlog import create_logger
@@ -55,9 +55,16 @@
             'pass': fabric_onos.rest_password
         }
 
-    def get_bng_port(self, o):
-        # TODO(smbaker): Get BNG port from somewhere
-        return 2
+    def make_handle(self, s_tag, west_dpid):
+        # Generate a backend_handle that uniquely identifies the cross connect. ONOS doesn't provide us a handle, so
+        # we make up our own. This helps us to detect other FabricCrossconnectServiceInstance using the same
+        # entry, as well as to be able to extract the necessary information to delete the entry later.
+        return "%d/%s" % (s_tag, west_dpid)
+
+    def extract_handle(self, backend_handle):
+        (s_tag, dpid) = backend_handle.split("/",1)
+        s_tag = int(s_tag)
+        return (s_tag, dpid)
 
     def sync_record(self, o):
         self.log.info("Sync'ing Fabric Crossconnect Service Instance", service_instance=o)
@@ -67,15 +74,19 @@
         si = ServiceInstance.objects.get(id=o.id)
 
         s_tag = si.get_westbound_service_instance_properties("s_tag")
-        west_dpid = si.get_westbound_service_instance_properties("switch_datapath_id")
+        dpid = si.get_westbound_service_instance_properties("switch_datapath_id")
         west_port = si.get_westbound_service_instance_properties("switch_port")
-        east_port = self.get_bng_port(o)
 
-        data = { "deviceId": west_dpid,
+        bng_mappings = BNGPortMapping.objects.filter(s_tag = s_tag)
+        if not bng_mappings:
+            raise Exception("Unable to determine BNG port for s_tag %s" % s_tag)
+        east_port = bng_mappings[0].switch_port
+
+        data = { "deviceId": dpid,
                  "vlanId": s_tag,
                  "ports": [ west_port, east_port ] }
 
-        url = onos['url'] + '/onos/segmentsrouting/xconnect'
+        url = onos['url'] + '/onos/segmentrouting/xconnect'
 
         self.log.info("Sending request to ONOS", url=url, body=data)
 
@@ -84,25 +95,29 @@
         if r.status_code != 200:
             raise Exception("Failed to create fabric crossconnect in ONOS: %s" % r.text)
 
+        o.backend_handle = self.make_handle(s_tag, dpid)
+        o.save(update_fields=["backend_handle"])
+
         self.log.info("ONOS response", res=r.text)
 
     def delete_record(self, o):
         self.log.info("Deleting Fabric Crossconnect Service Instance", service_instance=o)
 
-        # TODO(smbaker): make sure it's not in use by some other subscriber
-
-        if o.enacted:
+        if o.backend_handle:
             onos = self.get_fabric_onos_info(o)
 
-            si = ServiceInstance.objects.get(id=o.id)
+            # If some other subscriber is using the same entry, then we shouldn't delete it
+            other_subscribers = FabricCrossconnectServiceInstance.objects.filter(backend_handle=o.backend_handle)
+            other_subscribers = [x for x in other_subscribers if x.id != o.id]
+            if other_subscribers:
+                self.log.info("Other subscribers exist using same fabric crossconnect entry. Not deleting.")
+                return
 
-            # TODO(smbaker): unable to get westbound service_instance while deleting?
+            # backend_handle has everything we need in it to delete this entry.
+            (s_tag, dpid) = self.extract_handle(o.backend_handle)
 
-            s_tag = si.get_westbound_service_instance_properties("s_tag")
-            west_dpid = si.get_westbound_service_instance_properties("switch_datapath_id")
-
-            data = { "deviceId": west_dpid,
-                     "vlanID": s_tag }
+            data = { "deviceId": dpid,
+                     "vlanId": s_tag }
 
             url = onos['url'] + '/onos/segmentrouting/xconnect'
 
diff --git a/xos/synchronizer/steps/test_sync_fabric_crossconnect_service_instance.py b/xos/synchronizer/steps/test_sync_fabric_crossconnect_service_instance.py
index 5140ea6..4455497 100644
--- a/xos/synchronizer/steps/test_sync_fabric_crossconnect_service_instance.py
+++ b/xos/synchronizer/steps/test_sync_fabric_crossconnect_service_instance.py
@@ -15,7 +15,7 @@
 import unittest
 
 import functools
-from mock import patch, call, Mock, PropertyMock
+from mock import patch, call, Mock, PropertyMock, MagicMock
 import requests_mock
 import multistructlog
 from multistructlog import create_logger
@@ -44,10 +44,8 @@
     raise Exception("Unable to find service=%s xproto=%s" % (service_name, xproto_name))
 # END generate model from xproto
 
-def mock_get_westbound_service_instance_properties(prop):
-    if prop == "status":
-        return "enabled"
-    return prop
+def mock_get_westbound_service_instance_properties(props, prop):
+    return props[prop]
 
 def match_json(desired, req):
     if desired!=req.json():
@@ -55,7 +53,7 @@
         return False
     return True
 
-class TestSyncOLTDevice(unittest.TestCase):
+class TestSyncFabricCrossconnectServiceInstance(unittest.TestCase):
 
     def setUp(self):
         global DeferredException
@@ -81,14 +79,139 @@
         for (k, v) in model_accessor.all_model_classes.items():
             globals()[k] = v
 
-
         self.sync_step = SyncFabricCrossconnectServiceInstance
         self.sync_step.log = Mock()
 
-        # TODO: stuff
+        # mock onos-fabric
+        self.onos_fabric = Service(name = "onos-fabric",
+                              rest_hostname = "onos-fabric",
+                              rest_port = "8181",
+                              rest_username = "onos",
+                              rest_password = "rocks")
 
-    def test_sync(self):
-        pass
+        self.service = FabricCrossconnectService(name = "fcservice",
+                                                 provider_services = [self.onos_fabric])
+
+    def mock_westbound(self, fsi, s_tag, switch_datapath_id, switch_port):
+        # Mock out a ServiceInstance so the syncstep can call get_westbound_service_instance_properties on it
+        si = ServiceInstance(id=fsi.id)
+        si.get_westbound_service_instance_properties = functools.partial(
+            mock_get_westbound_service_instance_properties,
+            {"s_tag": s_tag,
+             "switch_datapath_id": switch_datapath_id,
+             "switch_port": switch_port})
+        return si
+
+    def test_format_url(self):
+        url = self.sync_step().format_url("foo.com/bar")
+        self.assertEqual(url, "http://foo.com/bar")
+
+        url = self.sync_step().format_url("http://foo.com/bar")
+        self.assertEqual(url, "http://foo.com/bar")
+
+    def test_make_handle_extract_handle(self):
+        h = self.sync_step().make_handle(222, "of:0000000000000201")
+        (s_tag, dpid) = self.sync_step().extract_handle(h)
+
+        self.assertEqual(s_tag, 222)
+        self.assertEqual(dpid, "of:0000000000000201")
+
+    def test_get_fabric_onos_init(self):
+        fsi = FabricCrossconnectServiceInstance(id=7777, owner=self.service)
+
+        d = self.sync_step().get_fabric_onos_info(fsi)
+
+        self.assertEqual(d["url"], "http://onos-fabric:8181")
+        self.assertEqual(d["user"], "onos")
+        self.assertEqual(d["pass"], "rocks")
+
+
+    @requests_mock.Mocker()
+    def test_sync(self, m):
+        with patch.object(ServiceInstance.objects, "get_items") as serviceinstance_objects, \
+            patch.object(BNGPortMapping.objects, "get_items") as bng_objects, \
+            patch.object(FabricCrossconnectServiceInstance, "save") as fcsi_save:
+
+            fsi = FabricCrossconnectServiceInstance(id=7777, owner=self.service)
+
+            si = self.mock_westbound(fsi, s_tag=111, switch_datapath_id = "of:0000000000000201", switch_port = 3)
+            serviceinstance_objects.return_value = [si]
+
+            bngmapping = BNGPortMapping(s_tag=111, switch_port=4)
+            bng_objects.return_value = [bngmapping]
+
+            desired_data = {"deviceId": "of:0000000000000201",
+                    "vlanId": 111,
+                    "ports": [3, 4]}
+
+            m.post("http://onos-fabric:8181/onos/segmentrouting/xconnect",
+                   status_code=200,
+                   additional_matcher=functools.partial(match_json, desired_data))
+
+            self.sync_step().sync_record(fsi)
+            self.assertTrue(m.called)
+
+            self.assertEqual(fsi.backend_handle, "111/of:0000000000000201")
+            fcsi_save.assert_called()
+
+    def test_sync_no_bng_mapping(self):
+        with patch.object(ServiceInstance.objects, "get_items") as serviceinstance_objects, \
+            patch.object(FabricCrossconnectServiceInstance, "save") as fcsi_save:
+
+            fsi = FabricCrossconnectServiceInstance(id=7777, owner=self.service)
+
+            si = self.mock_westbound(fsi, s_tag=111, switch_datapath_id = "of:0000000000000201", switch_port = 3)
+            serviceinstance_objects.return_value = [si]
+
+            with self.assertRaises(Exception) as e:
+                self.sync_step().sync_record(fsi)
+
+            self.assertEqual(e.exception.message, "Unable to determine BNG port for s_tag 111")
+
+    @requests_mock.Mocker()
+    def test_delete(self, m):
+        with patch.object(FabricCrossconnectServiceInstance.objects, "get_items") as fcsi_objects, \
+                patch.object(FabricCrossconnectServiceInstance, "save") as fcsi_save:
+            fsi = FabricCrossconnectServiceInstance(id=7777, owner=self.service,
+                                                    backend_handle="111/of:0000000000000201",
+                                                    enacted=True)
+
+            fcsi_objects.return_value=[fsi]
+
+            desired_data = {"deviceId": "of:0000000000000201",
+                            "vlanId": 111}
+
+            m.delete("http://onos-fabric:8181/onos/segmentrouting/xconnect",
+                   status_code=204,
+                   additional_matcher=functools.partial(match_json, desired_data))
+
+            self.sync_step().delete_record(fsi)
+            self.assertTrue(m.called)
+
+    @requests_mock.Mocker()
+    def test_delete_in_use(self, m):
+        with patch.object(FabricCrossconnectServiceInstance.objects, "get_items") as fcsi_objects:
+            # The subscriber we want to delete
+            fsi = FabricCrossconnectServiceInstance(id=7777, owner=self.service,
+                                                    backend_handle="111/of:0000000000000201",
+                                                    enacted=True)
+
+            # Another subscriber using the same (s_tag, dpid) pair
+            fsi2 = FabricCrossconnectServiceInstance(id=7778, owner=self.service,
+                                                     backend_handle="111/of:0000000000000201",
+                                                     enacted=True)
+
+            fcsi_objects.return_value=[fsi, fsi2]
+
+            desired_data = {"deviceId": "of:0000000000000201",
+                            "vlanId": 111}
+
+            m.delete("http://onos-fabric:8181/onos/segmentrouting/xconnect",
+                   status_code=204,
+                   additional_matcher=functools.partial(match_json, desired_data))
+
+            self.sync_step().delete_record(fsi)
+            self.assertFalse(m.called)
 
     def tearDown(self):
         self.o = None