CORD-2538 implement UnloadModels

Change-Id: I28bcc840cc7d69580ffa7c27195ccadebbe5d63a
diff --git a/xos/coreapi/Makefile b/xos/coreapi/Makefile
index 9c20c37..c07f041 100644
--- a/xos/coreapi/Makefile
+++ b/xos/coreapi/Makefile
@@ -19,7 +19,10 @@
 start:
 	bash -c "source env.sh && python ./core_main.py"
 
-prep: app_lists rebuild_protos compile_protos try_models makemigrations migrate
+prep: unload_unwanted app_lists rebuild_protos compile_protos try_models makemigrations migrate
+
+unload_unwanted:
+	python unload_unwanted_apps.py
 
 app_lists:
 	python app_list_builder.py
diff --git a/xos/coreapi/dynamicbuild.py b/xos/coreapi/dynamicbuild.py
index 13032b3..da59b37 100644
--- a/xos/coreapi/dynamicbuild.py
+++ b/xos/coreapi/dynamicbuild.py
@@ -53,7 +53,7 @@
         for item in request.attics:
             self.pre_validate_file(item)
 
-    def handle_loadmodels_request(self, request):
+    def load_manifest_from_request(self, request):
         manifest_fn = os.path.join(self.manifest_dir, request.name + ".json")
         if os.path.exists(manifest_fn):
             try:
@@ -64,9 +64,14 @@
         else:
             manifest = {}
 
+        return (manifest, manifest_fn)
+
+    def handle_loadmodels_request(self, request):
+        (manifest, manifest_fn) = self.load_manifest_from_request(request)
+
         # TODO: Check version number to make sure this is not a downgrade ?
 
-        hash = self.generate_request_hash(request)
+        hash = self.generate_request_hash(request, state="load")
         if hash == manifest.get("hash"):
             # The hash of the incoming request is identical to the manifest that we have saved, so this request is a
             # no-op.
@@ -75,7 +80,7 @@
 
         self.pre_validate_models(request)
 
-        manifest = self.save_models(request, hash=hash)
+        manifest = self.save_models(request, state="load", hash=hash)
 
         self.run_xosgenx_service(manifest)
 
@@ -86,27 +91,49 @@
 
         return self.SOMETHING_CHANGED
 
-        # TODO: schedule a restart
+    def handle_unloadmodels_request(self, request):
+        (manifest, manifest_fn) = self.load_manifest_from_request(request)
 
-    def generate_request_hash(self, request):
+        # TODO: Check version number to make sure this is not a downgrade ?
+
+        hash = self.generate_request_hash(request, state="unload")
+        if hash == manifest.get("hash"):
+            # The hash of the incoming request is identical to the manifest that we have saved, so this request is a
+            # no-op.
+            log.info("Models are already up-to-date; skipping dynamic unload.", name=request.name)
+            return self.NOTHING_TO_DO
+
+        manifest = self.save_models(request, state="unload", hash=hash)
+
+        self.remove_service(manifest)
+
+        log.debug("Saving service manifest", name=request.name)
+        file(manifest_fn, "w").write(json.dumps(manifest))
+
+        log.info("Finished UnloadModels request", name=request.name)
+
+        return self.SOMETHING_CHANGED
+
+    def generate_request_hash(self, request, state):
         # TODO: could we hash the request rather than individually hashing the subcomponents of the request?
         m = hashlib.sha1()
         m.update(request.name)
         m.update(request.version)
-        for item in request.xprotos:
-            m.update(item.filename)
-            m.update(item.contents)
-        for item in request.decls:
-            m.update(item.filename)
-            m.update(item.contents)
-        for item in request.decls:
-            m.update(item.filename)
-            m.update(item.contents)
+        if (state == "load"):
+            for item in request.xprotos:
+                m.update(item.filename)
+                m.update(item.contents)
+            for item in request.decls:
+                m.update(item.filename)
+                m.update(item.contents)
+            for item in request.decls:
+                m.update(item.filename)
+                m.update(item.contents)
         return m.hexdigest()
 
-    def save_models(self, request, hash=None):
+    def save_models(self, request, state, hash=None):
         if not hash:
-            hash = self.generate_request_hash(request)
+            hash = self.generate_request_hash(request, state)
 
         service_dir = os.path.join(self.services_dir, request.name)
         if not os.path.exists(service_dir):
@@ -126,6 +153,7 @@
         service_manifest = {"name": request.name,
                             "version": request.version,
                             "hash": hash,
+                            "state": state,
                             "dir": service_dir,
                             "manifest_fn": manifest_fn,
                             "dest_dir": os.path.join(self.services_dest_dir, request.name),
@@ -133,22 +161,23 @@
                             "decls": [],
                             "attics": []}
 
-        for item in request.xprotos:
-            file(os.path.join(service_dir, item.filename), "w").write(item.contents)
-            service_manifest["xprotos"].append({"filename": item.filename})
+        if (state == "load"):
+            for item in request.xprotos:
+                file(os.path.join(service_dir, item.filename), "w").write(item.contents)
+                service_manifest["xprotos"].append({"filename": item.filename})
 
-        for item in request.decls:
-            file(os.path.join(service_dir, item.filename), "w").write(item.contents)
-            service_manifest["decls"].append({"filename": item.filename})
+            for item in request.decls:
+                file(os.path.join(service_dir, item.filename), "w").write(item.contents)
+                service_manifest["decls"].append({"filename": item.filename})
 
-        if request.attics:
-            attic_dir = os.path.join(service_dir, "attic")
-            service_manifest["attic_dir"] = attic_dir
-            if not os.path.exists(attic_dir):
-                os.makedirs(attic_dir)
-            for item in request.attics:
-                file(os.path.join(attic_dir, item.filename), "w").write(item.contents)
-                service_manifest["attics"].append({"filename": item.filename})
+            if request.attics:
+                attic_dir = os.path.join(service_dir, "attic")
+                service_manifest["attic_dir"] = attic_dir
+                if not os.path.exists(attic_dir):
+                    os.makedirs(attic_dir)
+                for item in request.attics:
+                    file(os.path.join(attic_dir, item.filename), "w").write(item.contents)
+                    service_manifest["attics"].append({"filename": item.filename})
 
         return service_manifest
 
@@ -218,3 +247,26 @@
             shutil.copyfile(attic_header_py_src, service_header_py_dest)
         elif os.path.exists(service_header_py_dest):
             os.remove(service_header_py_dest)
+
+    def remove_service(self, manifest):
+        # remove any xproto files, otherwise "make rebuild_protos" will pick them up
+        if os.path.exists(manifest["dir"]):
+            for fn in os.listdir(manifest["dir"]):
+                fn = os.path.join(manifest["dir"], fn)
+                if fn.endswith(".xproto"):
+                    os.remove(fn)
+
+        # Rather than trying to unmigrate while the core is running, let's handle unmigrating the service while we're
+        # outside of the core process. We're going to save the manifest file, and the manifest file will have
+        # {"state": "unload"} in it. That can be our external signal to unmigrate.
+
+        # This is what unmigrate will do, external to this process:
+        #     1) remove the models (./manage.py migrate my_app_name zero)
+        #     2) remove the contenttypes
+        #            # does step 1 already do this?
+        #            from django.contrib.contenttypes.models import ContentType
+        #            for c in ContentType.objects.all():
+        #                if not c.model_class():
+        #                    print "deleting %s" % c
+        #                    c.delete()
+        #     3) Remove the service files
diff --git a/xos/coreapi/protos/dynamicload.proto b/xos/coreapi/protos/dynamicload.proto
index d4142a1..4504580 100644
--- a/xos/coreapi/protos/dynamicload.proto
+++ b/xos/coreapi/protos/dynamicload.proto
@@ -37,6 +37,11 @@
     LoadModelsStatus status = 1;
 };
 
+message UnloadModelsRequest {
+    string name = 1;
+    string version = 2;
+};
+
 service dynamicload {
   rpc LoadModels(LoadModelsRequest) returns (LoadModelsReply) {
         option (google.api.http) = {
@@ -44,4 +49,10 @@
             body: "*"
         };
   }
+  rpc UnloadModels(UnloadModelsRequest) returns (LoadModelsReply) {
+        option (google.api.http) = {
+            post: "/xosapi/v1/dynamicload/unload_models"
+            body: "*"
+        };
+  }
 };
\ No newline at end of file
diff --git a/xos/coreapi/test_dynamicbuild.py b/xos/coreapi/test_dynamicbuild.py
index 35359ac..f6838d5 100644
--- a/xos/coreapi/test_dynamicbuild.py
+++ b/xos/coreapi/test_dynamicbuild.py
@@ -13,6 +13,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import json
 import os
 import shutil
 import sys
@@ -35,6 +36,11 @@
         for (k,v) in kwargs.items():
             setattr(self, k, v)
 
+class DynamicUnloadRequest():
+    def __init__(self, **kwargs):
+        for (k,v) in kwargs.items():
+            setattr(self, k, v)
+
 class TestDynamicBuild(unittest.TestCase):
     def setUp(self):
         global dynamicbuild
@@ -82,6 +88,9 @@
                                                   version = "1",
                                                   xprotos = [self.example_xproto_item])
 
+        self.example_unload_request = DynamicUnloadRequest(name = "exampleservice",
+                                                  version = "1")
+
         self.builder = dynamicbuild.DynamicBuilder(base_dir = self.base_dir)
 
     def tearDown(self):
@@ -95,16 +104,18 @@
         self.builder.pre_validate_models(self.example_request)
 
     def test_generate_request_hash(self):
-        hash = self.builder.generate_request_hash(self.example_request)
+        hash = self.builder.generate_request_hash(self.example_request, state="load")
         self.assertEqual(hash, "162de5012a8399883344085cbc232a2e627c5091")
 
     def test_handle_loadmodels_request(self):
         with patch.object(dynamicbuild.DynamicBuilder, "save_models", wraps=self.builder.save_models) as save_models, \
-             patch.object(dynamicbuild.DynamicBuilder, "run_xosgenx_service", wraps=self.builder.run_xosgenx_service) as run_xosgenx_service:
+             patch.object(dynamicbuild.DynamicBuilder, "run_xosgenx_service", wraps=self.builder.run_xosgenx_service) as run_xosgenx_service, \
+             patch.object(dynamicbuild.DynamicBuilder, "remove_service", wraps=self.builder.remove_service) as remove_service:
             result = self.builder.handle_loadmodels_request(self.example_request)
 
             save_models.assert_called()
             run_xosgenx_service.assert_called()
+            remove_service.assert_not_called()
 
             self.assertEqual(result, self.builder.SOMETHING_CHANGED)
 
@@ -118,6 +129,27 @@
             self.assertTrue(os.path.exists(os.path.join(service_dir, "models.py")))
             self.assertTrue(os.path.exists(os.path.join(service_dir, "security.py")))
 
+            manifest = json.loads(open(os.path.join(self.builder.manifest_dir, "exampleservice.json"), "r").read())
+            self.assertEqual(manifest.get("state"), "load")
+
+    def test_handle_unloadmodels_request(self):
+        with patch.object(dynamicbuild.DynamicBuilder, "save_models", wraps=self.builder.save_models) as save_models, \
+             patch.object(dynamicbuild.DynamicBuilder, "run_xosgenx_service", wraps=self.builder.run_xosgenx_service) as run_xosgenx_service, \
+             patch.object(dynamicbuild.DynamicBuilder, "remove_service", wraps=self.builder.remove_service) as remove_service:
+            result = self.builder.handle_unloadmodels_request(self.example_unload_request)
+
+            save_models.assert_called()
+            run_xosgenx_service.assert_not_called()
+            remove_service.assert_called()
+
+            self.assertEqual(result, self.builder.SOMETHING_CHANGED)
+
+            self.assertTrue(os.path.exists(self.builder.manifest_dir))
+            self.assertTrue(os.path.exists(os.path.join(self.builder.manifest_dir, "exampleservice.json")))
+
+            manifest = json.loads(open(os.path.join(self.builder.manifest_dir, "exampleservice.json"), "r").read())
+            self.assertEqual(manifest.get("state"), "unload")
+
     def test_handle_loadmodels_request_twice(self):
         result = self.builder.handle_loadmodels_request(self.example_request)
         self.assertEqual(result, self.builder.SOMETHING_CHANGED)
@@ -126,7 +158,7 @@
         self.assertEqual(result, self.builder.NOTHING_TO_DO)
 
     def test_save_models(self):
-        manifest = self.builder.save_models(self.example_request)
+        manifest = self.builder.save_models(self.example_request, state="load")
 
         dynamic_dir = os.path.join(self.base_dir, "dynamic_services", "exampleservice")
         service_dir = os.path.join(self.base_dir, "services", "exampleservice")
@@ -138,6 +170,19 @@
         self.assertEqual(manifest["dest_dir"], service_dir)
         self.assertEqual(len(manifest["xprotos"]), 1)
 
+    def test_save_models_precomputed_hash(self):
+        manifest = self.builder.save_models(self.example_request, state="load", hash="1234")
+
+        dynamic_dir = os.path.join(self.base_dir, "dynamic_services", "exampleservice")
+        service_dir = os.path.join(self.base_dir, "services", "exampleservice")
+
+        self.assertEqual(manifest["name"], self.example_request.name)
+        self.assertEqual(manifest["version"], self.example_request.version)
+        self.assertEqual(manifest["hash"], "1234")
+        self.assertEqual(manifest["dir"], dynamic_dir)
+        self.assertEqual(manifest["dest_dir"], service_dir)
+        self.assertEqual(len(manifest["xprotos"]), 1)
+
 def main():
     unittest.main()
 
diff --git a/xos/coreapi/unload_unwanted_apps.py b/xos/coreapi/unload_unwanted_apps.py
new file mode 100644
index 0000000..a4a1120
--- /dev/null
+++ b/xos/coreapi/unload_unwanted_apps.py
@@ -0,0 +1,49 @@
+
+# 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.
+
+import json
+import os
+import shutil
+
+class AppUnloader(object):
+    def __init__(self):
+        self.manifests_dir = "/opt/xos/dynamic_services/manifests"
+
+    def unload_all_eligible(self):
+        if not os.path.exists(self.manifests_dir):
+            # nothing to do...
+            return
+
+        for fn in os.listdir(self.manifests_dir):
+            if fn.endswith(".json"):
+                manifest_fn = os.path.join(self.manifests_dir, fn)
+                manifest = json.loads(open(manifest_fn).read())
+                if manifest.get("state") == "unload":
+                    self.unload_service(manifest)
+
+    def unload_service(self, manifest):
+        if not os.path.exists(manifest["dest_dir"]):
+            # service is deleted -- nothing to do
+            return
+
+        os.system("cd /opt/xos; python ./manage.py migrate %s zero" % manifest["name"])
+
+        # be paranoid about calling rmtree
+        assert(os.path.abspath(manifest["dest_dir"]).startswith("/opt/xos"))
+
+        shutil.rmtree(manifest["dest_dir"])
+
+if __name__ == "__main__":
+    AppUnloader().unload_all_eligible()
diff --git a/xos/coreapi/xos_dynamicload_api.py b/xos/coreapi/xos_dynamicload_api.py
index f7d79ed..c7327f8 100644
--- a/xos/coreapi/xos_dynamicload_api.py
+++ b/xos/coreapi/xos_dynamicload_api.py
@@ -35,10 +35,7 @@
     def stop(self):
         pass
 
-    #@translate_exceptions
     def LoadModels(self, request, context):
-        print "load models"
-
         try:
             builder = DynamicBuilder()
             result = builder.handle_loadmodels_request(request)
@@ -54,4 +51,20 @@
             import traceback; traceback.print_exc()
             raise e
 
+    def UnloadModels(self, request, context):
+        try:
+            builder = DynamicBuilder()
+            result = builder.handle_unloadmodels_request(request)
+
+            if (result == builder.SOMETHING_CHANGED):
+                self.server.delayed_shutdown(5)
+
+            response = dynamicload_pb2.LoadModelsReply()
+            response.status = response.SUCCESS
+
+            return response
+        except Exception, e:
+            import traceback; traceback.print_exc()
+            raise e
+
 
diff --git a/xos/xos_client/xossh b/xos/xos_client/xossh
index 600211f..3edc606 100644
--- a/xos/xos_client/xossh
+++ b/xos/xos_client/xossh
@@ -112,10 +112,32 @@
 def listModelDefs():
     return current_client.modeldefs.ListModelDefs(Empty())
 
+def loadModels(name, version, xproto_filenames=[], decl_filenames=[], attic_filenames=[]):
+    request = current_client.dynamicload_pb2.LoadModelsRequest(name=name, version=version)
+    for fn in xproto_filenames:
+        item = request.xprotos.add()
+        item.filename = fn
+        item.contents = open(fn).read()
+    for fn in decl_filenames:
+        item = request.decls.add()
+        item.filename = fn
+        item.contents = open(fn).read()
+    for fn in attic_filenames:
+        item = request.attics.add()
+        item.filename = fn
+        item.contents = open(fn).read()
+    return current_client.dynamicload.LoadModels(request)
+
+def unloadModels(name, version):
+    request = current_client.dynamicload_pb2.UnloadModelsRequest(name=name, version=version)
+    return current_client.dynamicload.UnloadModels(request)
+
 def listUtility():
     print 'setDirtyModels(class_name=None)'
     print 'listDirtyModels(class_name=None)'
     print 'listModelDefs()'
+    print 'loadModels(name, version, xproto_filenames, decl_filenames, attic_filenames)'
+    print 'unloadModels(name, version)'
 
 def examples():
     print 'Slice.objects.all() # list all slices'