Methods for managing VIMs, SDNs 86/5886/5
authortierno <alfonso.tiernosepulveda@telefonica.com>
Mon, 19 Mar 2018 09:28:22 +0000 (10:28 +0100)
committertierno <alfonso.tiernosepulveda@telefonica.com>
Tue, 3 Apr 2018 11:10:49 +0000 (13:10 +0200)
Change-Id: I34c3ac84c811dcfe1d71fe7e6fec0b820993d541
Signed-off-by: tierno <alfonso.tiernosepulveda@telefonica.com>
12 files changed:
Dockerfile
osm_nbi/engine.py
osm_nbi/html_out.py
osm_nbi/msgbase.py
osm_nbi/msgkafka.py
osm_nbi/msglocal.py
osm_nbi/nbi.cfg
osm_nbi/nbi.py
osm_nbi/test/test.py
osm_nbi/validation.py [new file with mode: 0644]
setup.py
stdeb.cfg

index 29d8376..68c7850 100644 (file)
@@ -6,7 +6,7 @@ WORKDIR /app/osm_nbi
 # Copy the current directory contents into the container at /app
 ADD . /app
 
-RUN apt-get update && apt-get -y install git  python3 \
+RUN apt-get update && apt-get -y install git  python3 python3-jsonschema \
     python3-cherrypy3 python3-pymongo python3-yaml python3-pip \
     && pip3 install aiokafka \
     && mkdir -p /app/storage/kafka && mkdir -p /app/log 
index a15420f..e55c7ec 100644 (file)
@@ -18,6 +18,7 @@ from msgbase import MsgException
 from http import HTTPStatus
 from time import time
 from copy import deepcopy
+from validation import validate_input, ValidationError
 
 __author__ = "Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
 
@@ -276,6 +277,11 @@ class Engine(object):
 
         elif item == "nsrs":
             pass
+        elif item == "vims" or item == "sdns":
+            if self.db.get_one(item, {"name": indata.get("name")}, fail_on_empty=False, fail_on_more=False):
+                raise EngineException("name '{}' already exist for {}".format(indata["name"], item),
+                                      HTTPStatus.CONFLICT)
+
 
     def _format_new_data(self, session, item, indata, admin=None):
         now = time()
@@ -306,6 +312,9 @@ class Engine(object):
                 indata["_admin"]["onboardingState"] = "CREATED"
                 indata["_admin"]["operationalState"] = "DISABLED"
                 indata["_admin"]["usageSate"] = "NOT_IN_USE"
+            elif item in ("vims", "sdns"):
+                indata["_admin"]["operationalState"] = "PROCESSING"
+
             if storage:
                 indata["_admin"]["storage"] = storage
         indata["_id"] = _id
@@ -500,25 +509,14 @@ class Engine(object):
         Creates a new entry into database. For nsds and vnfds it creates an almost empty DISABLED  entry,
         that must be completed with a call to method upload_content
         :param session: contains the used login username and working project
-        :param item: it can be: users, projects, nsrs, nsds, vnfds
+        :param item: it can be: users, projects, vims, sdns, nsrs, nsds, vnfds
         :param indata: data to be inserted
         :param kwargs: used to override the indata descriptor
         :param headers: http request headers
         :return: _id, transaction_id: identity of the inserted data. or transaction_id if Content-Range is used
         """
-        # TODO validate input. Check not exist at database
-        # TODO add admin and status
 
         transaction = None
-        # if headers.get("Content-Range") or "application/gzip" in headers.get("Content-Type") or \
-        #     "application/x-gzip" in headers.get("Content-Type") or "application/zip" in headers.get("Content-Type") or \
-        #     "text/plain" in headers.get("Content-Type"):
-        #     if not indata:
-        #         raise EngineException("Empty payload")
-        #     transaction = self._new_item_partial(session, item, indata, headers)
-        #     if "desc" not in transaction:
-        #         return transaction["_id"], False
-        #     indata = transaction["desc"]
 
         item_envelop = item
         if item in ("nsds", "vnfds"):
@@ -552,6 +550,11 @@ class Engine(object):
             except IndexError:
                 raise EngineException(
                     "Invalid query string '{}'. Index '{}' out of  range".format(k, kitem_old))
+        try:
+            validate_input(content, item, new=True)
+        except ValidationError as e:
+            raise EngineException(e, HTTPStatus.UNPROCESSABLE_ENTITY)
+
         if not indata and item not in ("nsds", "vnfds"):
             raise EngineException("Empty payload")
 
@@ -568,6 +571,14 @@ class Engine(object):
         _id = self.db.create(item, content)
         if item == "nsrs":
             self.msg.write("ns", "create", _id)
+        elif item == "vims":
+            msg_data = self.db.get_one(item, {"_id": _id})
+            msg_data.pop("_admin", None)
+            self.msg.write("vim_account", "create", msg_data)
+        elif item == "sdns":
+            msg_data = self.db.get_one(item, {"_id": _id})
+            msg_data.pop("_admin", None)
+            self.msg.write("sdn", "create", msg_data)
         return _id
 
     def _add_read_filter(self, session, item, filter):
@@ -645,7 +656,6 @@ class Engine(object):
                                       "", http_code=HTTPStatus.NOT_ACCEPTABLE)
             return self.fs.file_open((storage['folder'], storage['zipfile']), "rb"), "application/zip"
 
-
     def get_item_list(self, session, item, filter={}):
         """
         Get a list of items
@@ -701,17 +711,20 @@ class Engine(object):
         filter = {"_id": _id}
         self._add_delete_filter(session, item, filter)
 
-        if item == "nsrs":
+        if item in ("nsrs", "vims", "sdns"):
             desc = self.db.get_one(item, filter)
             desc["_admin"]["to_delete"] = True
             self.db.replace(item, _id, desc)   # TODO change to set_one
-            self.msg.write("ns", "delete", _id)
-            return {"deleted": 1}
+            if item == "nsrs":
+                self.msg.write("ns", "delete", _id)
+            elif item == "vims":
+                self.msg.write("vim_account", "delete", {"_id": _id})
+            elif item == "sdns":
+                self.msg.write("sdn", "delete", {"_id": _id})
+            return {"deleted": 1}  # TODO indicate an offline operation to return 202 ACCEPTED
 
         v = self.db.del_one(item, filter)
         self.fs.file_delete(_id, ignore_non_exist=True)
-        if item == "nsrs":
-            self.msg.write("ns", "delete", _id)
         return v
 
     def prune(self):
@@ -766,11 +779,22 @@ class Engine(object):
             except IndexError:
                 raise EngineException(
                     "Invalid query string '{}'. Index '{}' out of  range".format(k, kitem_old))
+        try:
+            validate_input(content, item, new=False)
+        except ValidationError as e:
+            raise EngineException(e, HTTPStatus.UNPROCESSABLE_ENTITY)
 
         _deep_update(content, indata)
         self._validate_new_data(session, item, content, id)
         # self._format_new_data(session, item, content)
         self.db.replace(item, id, content)
+        if item in ("vims", "sdns"):
+            indata.pop("_admin", None)
+            indata["_id"] = id
+            if item == "vims":
+                self.msg.write("vim_account", "edit", indata)
+            elif item == "sdns":
+                self.msg.write("sdn", "edit", indata)
         return id
 
     def edit_item(self, session, item, _id, indata={}, kwargs=None):
index f37b5dd..23ee97b 100644 (file)
@@ -109,9 +109,9 @@ def format(data, request, response, session):
         body += html_body_error.format(yaml.safe_dump(data, explicit_start=True, indent=4, default_flow_style=False))
     elif isinstance(data, (list, tuple)):
         if request.path_info == "/vnfpkgm/v1/vnf_packages_content":
-            body += html_upload_body.format("VNFD", request.path_info)
+            body += html_upload_body.format(request.path_info, "VNFD")
         elif request.path_info == "/nsd/v1/ns_descriptors_content":
-            body += html_upload_body.format("NSD", request.path_info)
+            body += html_upload_body.format(request.path_info, "NSD")
         for k in data:
             if isinstance(k, dict):
                 data_id = k.pop("_id", None)
@@ -124,6 +124,9 @@ def format(data, request, response, session):
         else:
             body += '<a href="/osm/{}?METHOD=DELETE"> <img src="/osm/static/delete.png" height="25" width="25"> </a>'.format(request.path_info)
         body += "<pre>" + yaml.safe_dump(data, explicit_start=True, indent=4, default_flow_style=False) + "</pre>"
+    elif data is None:
+        if request.method == "DELETE" or "METHOD=DELETE" in request.query_string:
+            body += "<pre> deleted </pre>"
     else:
         body = str(data)
     user_text = "    "
index a105414..25e8c80 100644 (file)
@@ -1,4 +1,5 @@
 
+import asyncio
 from http import HTTPStatus
 
 __author__ = "Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
@@ -30,12 +31,17 @@ class MsgBase(object):
     def connect(self, config):
         pass
 
-    def write(self, msg):
+    def disconnect(self):
         pass
 
-    def read(self):
+    def write(self, topic, key, msg):
         pass
 
-    def disconnect(self):
+    def read(self, topic):
         pass
 
+    async def aiowrite(self, topic, key, msg, loop):
+        pass
+
+    async def aioread(self, topic, loop):
+        pass
index 90c9c7f..96456af 100644 (file)
@@ -31,24 +31,36 @@ class MsgKafka(MsgBase):
         except Exception as e:  # TODO refine
             raise MsgException(str(e))
 
+    def disconnect(self):
+        try:
+            self.loop.close()
+        except Exception as e:  # TODO refine
+            raise MsgException(str(e))
+
     def write(self, topic, key, msg):
         try:
-            self.loop.run_until_complete(self.aiowrite(topic=topic, key=key, msg=yaml.safe_dump(msg, default_flow_style=True)))
+            self.loop.run_until_complete(self.aiowrite(topic=topic, key=key,
+                                                       msg=yaml.safe_dump(msg, default_flow_style=True),
+                                                       loop=self.loop))
 
         except Exception as e:
             raise MsgException("Error writing {} topic: {}".format(topic, str(e)))
 
     def read(self, topic):
-        #self.topic_lst.append(topic)
+        """
+        Read from one or several topics. it is non blocking returning None if nothing is available
+        :param topic: can be str: single topic; or str list: several topics
+        :return: topic, key, message; or None
+        """
         try:
-            return self.loop.run_until_complete(self.aioread(topic))
+            return self.loop.run_until_complete(self.aioread(topic, self.loop))
+        except MsgException:
+            raise
         except Exception as e:
             raise MsgException("Error reading {} topic: {}".format(topic, str(e)))
 
-    async def aiowrite(self, topic, key, msg, loop=None):
+    async def aiowrite(self, topic, key, msg, loop):
         try:
-            if not loop:
-                loop = self.loop
             self.producer = AIOKafkaProducer(loop=loop, key_serializer=str.encode, value_serializer=str.encode,
                                              bootstrap_servers=self.broker)
             await self.producer.start()
@@ -58,15 +70,24 @@ class MsgKafka(MsgBase):
         finally:
             await self.producer.stop()
 
-    async def aioread(self, topic, loop=None):
-        if not loop:
-            loop = self.loop
-        self.consumer = AIOKafkaConsumer(loop=loop, bootstrap_servers=self.broker)
-        await self.consumer.start()
-        self.consumer.subscribe([topic])
+    async def aioread(self, topic, loop):
+        """
+        Asyncio read from one or several topics. It blocks
+        :param topic: can be str: single topic; or str list: several topics
+        :param loop: asyncio loop
+        :return: topic, key, message
+        """
         try:
+            if isinstance(topic, (list, tuple)):
+                topic_list = topic
+            else:
+                topic_list = (topic,)
+
+            self.consumer = AIOKafkaConsumer(loop=loop, bootstrap_servers=self.broker)
+            await self.consumer.start()
+            self.consumer.subscribe(topic_list)
             async for message in self.consumer:
-                return yaml.load(message.key), yaml.load(message.value)
+                return message.topic, yaml.load(message.key), yaml.load(message.value)
         except KafkaError as e:
             raise MsgException(str(e))
         finally:
index a19c5c3..337321f 100644 (file)
@@ -3,9 +3,16 @@ import os
 import yaml
 import asyncio
 from msgbase import MsgBase, MsgException
+from time import sleep
 
 __author__ = "Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
 
+"""
+This emulated kafka bus by just using a shared file system. Usefull for testing or devops.
+One file is used per topic. Only one producer and one consumer is allowed per topic. Both consumer and producer 
+access to the same file. e.g. same volume if running with docker.
+One text line per message is used in yaml format
+"""
 
 class MsgLocal(MsgBase):
 
@@ -14,6 +21,7 @@ class MsgLocal(MsgBase):
         self.path = None
         # create a different file for each topic
         self.files = {}
+        self.buffer = {}
 
     def connect(self, config):
         try:
@@ -41,55 +49,63 @@ class MsgLocal(MsgBase):
         Insert a message into topic
         :param topic: topic
         :param key: key text to be inserted
-        :param msg: value object to be inserted
+        :param msg: value object to be inserted, can be str, object ...
         :return: None or raises and exception
         """
         try:
             if topic not in self.files:
                 self.files[topic] = open(self.path + topic, "a+")
-            yaml.safe_dump({key: msg}, self.files[topic], default_flow_style=True)
+            yaml.safe_dump({key: msg}, self.files[topic], default_flow_style=True, width=20000)
             self.files[topic].flush()
         except Exception as e:  # TODO refine
             raise MsgException(str(e))
 
-    def read(self, topic):
+    def read(self, topic, blocks=True):
+        """
+        Read from one or several topics. it is non blocking returning None if nothing is available
+        :param topic: can be str: single topic; or str list: several topics
+        :param blocks: indicates if it should wait and block until a message is present or returns None
+        :return: topic, key, message; or None if blocks==True
+        """
         try:
-            msg = ""
-            if topic not in self.files:
-                self.files[topic] = open(self.path + topic, "a+")
-                # ignore previous content
-                for line in self.files[topic]:
-                    if not line.endswith("\n"):
-                        msg = line
-            msg += self.files[topic].readline()
-            if not msg.endswith("\n"):
-                return None
-            msg_dict = yaml.load(msg)
-            assert len(msg_dict) == 1
-            for k, v in msg_dict.items():
-                return k, v
+            if isinstance(topic, (list, tuple)):
+                topic_list = topic
+            else:
+                topic_list = (topic, )
+            while True:
+                for single_topic in topic_list:
+                    if single_topic not in self.files:
+                        self.files[single_topic] = open(self.path + single_topic, "a+")
+                        self.buffer[single_topic] = ""
+                    self.buffer[single_topic] += self.files[single_topic].readline()
+                    if not self.buffer[single_topic].endswith("\n"):
+                        continue
+                    msg_dict = yaml.load(self.buffer[single_topic])
+                    self.buffer[single_topic] = ""
+                    assert len(msg_dict) == 1
+                    for k, v in msg_dict.items():
+                        return single_topic, k, v
+                if not blocks:
+                    return None
+                sleep(2)
         except Exception as e:  # TODO refine
             raise MsgException(str(e))
 
-    async def aioread(self, topic, loop=None):
+    async def aioread(self, topic, loop):
+        """
+        Asyncio read from one or several topics. It blocks
+        :param topic: can be str: single topic; or str list: several topics
+        :param loop: asyncio loop
+        :return: topic, key, message
+        """
         try:
-            msg = ""
-            if not loop:
-                loop = asyncio.get_event_loop()
-            if topic not in self.files:
-                self.files[topic] = open(self.path + topic, "a+")
-                # ignore previous content
-                for line in self.files[topic]:
-                    if not line.endswith("\n"):
-                        msg = line
             while True:
-                msg += self.files[topic].readline()
-                if msg.endswith("\n"):
-                    break
+                msg = self.read(topic, blocks=False)
+                if msg:
+                    return msg
                 await asyncio.sleep(2, loop=loop)
-            msg_dict = yaml.load(msg)
-            assert len(msg_dict) == 1
-            for k, v in msg_dict.items():
-                return k, v
+        except MsgException:
+            raise
         except Exception as e:  # TODO refine
             raise MsgException(str(e))
+
index d208367..e75643c 100644 (file)
@@ -26,7 +26,7 @@ server.ssl_module: "builtin"
 server.ssl_certificate: "./http/cert.pem"
 server.ssl_private_key: "./http/privkey.pem"
 server.ssl_pass_phrase: "osm4u"
-server.thread_pool: 10
+server.thread_pool: 1
 
 # Only for test. It works without authorization using the provided user and project:
 # test.user_not_authorized: "admin"
index 99f90aa..1cd61d5 100644 (file)
@@ -10,6 +10,7 @@ import logging
 from engine import Engine, EngineException
 from dbbase import DbException
 from fsbase import FsException
+from msgbase import MsgException
 from base64 import standard_b64decode
 #from os import getenv
 from http import HTTPStatus
@@ -18,7 +19,7 @@ from codecs import getreader
 from os import environ
 
 __author__ = "Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
-__version__ = "0.2"
+__version__ = "0.3"
 version_date = "Mar 2018"
 
 """
@@ -66,6 +67,10 @@ URL: /osm                                                       GET     POST
                 /<id>                                           O                       O     
             /projects                                           O       O
                 /<id>                                           O                       O     
+            /vims                                               O       O
+                /<id>                                           O                       O       O     
+            /sdns                                               O       O
+                /<id>                                           O                       O       O     
 
 query string.
     <attrName>[.<attrName>...]*[.<op>]=<value>[,<value>...]&...
@@ -123,14 +128,20 @@ class Server(object):
         self.valid_methods = {   # contains allowed URL and methods
             "admin": {
                 "v1": {
-                    "tokens": { "METHODS": ("GET", "POST", "DELETE"),
+                    "tokens": {"METHODS": ("GET", "POST", "DELETE"),
                         "<ID>": { "METHODS": ("GET", "DELETE")}
                     },
-                    "users": { "METHODS": ("GET", "POST"),
+                    "users": {"METHODS": ("GET", "POST"),
                         "<ID>": {"METHODS": ("GET", "POST", "DELETE")}
                     },
-                    "projects": { "METHODS": ("GET", "POST"),
-                        "<ID>": {"METHODS": ("GET", "POST", "DELETE")}
+                    "projects": {"METHODS": ("GET", "POST"),
+                        "<ID>": {"METHODS": ("GET", "DELETE")}
+                    },
+                    "vims": {"METHODS": ("GET", "POST"),
+                        "<ID>": {"METHODS": ("GET", "DELETE")}
+                    },
+                    "sdns": {"METHODS": ("GET", "POST"),
+                        "<ID>": {"METHODS": ("GET", "DELETE")}
                     },
                 }
             },
@@ -140,7 +151,7 @@ class Server(object):
                         "<ID>": {"METHODS": ("GET", "PUT", "DELETE")}
                     },
                     "ns_descriptors": { "METHODS": ("GET", "POST"),
-                        "<ID>": { "METHODS": ("GET", "DELETE"), "TODO": "PATCH",
+                        "<ID>": {"METHODS": ("GET", "DELETE"), "TODO": "PATCH",
                             "nsd_content": { "METHODS": ("GET", "PUT")},
                             "nsd": {"METHODS": "GET"},  # descriptor inside package
                             "artifacts": {"*": {"METHODS": "GET"}}
@@ -317,7 +328,10 @@ class Server(object):
         :param _format: The format to be set as Content-Type ir data is a file
         :return: None
         """
+        accept = cherrypy.request.headers.get("Accept")
         if data is None:
+            if accept and "text/html" in accept:
+                return html.format(data, cherrypy.request, cherrypy.response, session)
             cherrypy.response.status = HTTPStatus.NO_CONTENT.value
             return
         elif hasattr(data, "read"):  # file object
@@ -329,8 +343,7 @@ class Server(object):
                 cherrypy.response.headers["Content-Type"] = 'text/plain'
             # TODO check that cherrypy close file. If not implement pending things to close  per thread next
             return data
-        if "Accept" in cherrypy.request.headers:
-            accept = cherrypy.request.headers["Accept"]
+        if accept:
             if "application/json" in accept:
                 cherrypy.response.headers["Content-Type"] = 'application/json; charset=utf-8'
                 a = json.dumps(data, indent=4) + "\n"
@@ -390,6 +403,7 @@ class Server(object):
                 outdata = self.engine.new_token(session, indata, cherrypy.request.remote)
                 session = outdata
                 cherrypy.session['Authorization'] = outdata["_id"]
+                self._set_location_header("admin", "v1", "tokens", outdata["_id"])
                 # cherrypy.response.cookie["Authorization"] = outdata["id"]
                 # cherrypy.response.cookie["Authorization"]['expires'] = 3600
             elif method == "DELETE":
@@ -399,6 +413,7 @@ class Server(object):
                     session = self._authorization()
                     token_id = session["_id"]
                 outdata = self.engine.del_token(token_id)
+                oudata = None
                 session = None
                 cherrypy.session['Authorization'] = "logout"
                 # cherrypy.response.cookie["Authorization"] = token_id
@@ -477,7 +492,7 @@ class Server(object):
                     self.engine.msg.write(topic, k, yaml.load(v))
                 return "ok"
             except Exception as e:
-                return "Error: " + format(e)
+                return "Error: " + str(e)
 
         return_text = (
             "<html><pre>\nheaders:\n  args: {}\n".format(args) +
@@ -545,6 +560,8 @@ class Server(object):
         session = None
         outdata = None
         _format = None
+        method = "DONE"
+        engine_item = None
         try:
             if not topic or not version or not item:
                 raise NbiException("URL must contain at least 'topic/version/item'", HTTPStatus.METHOD_NOT_ALLOWED)
@@ -621,8 +638,8 @@ class Server(object):
                 if not _id:
                     outdata = self.engine.del_item_list(session, engine_item, kwargs)
                 else:  # len(args) > 1
-                    outdata = self.engine.del_item(session, engine_item, _id)
-                if item in ("ns_descriptors", "vnf_packages"):  # SOL005
+                    # TODO return 202 ACCEPTED for nsrs vims
+                    self.engine.del_item(session, engine_item, _id)
                     outdata = None
             elif method == "PUT":
                 if not indata and not kwargs:
@@ -638,11 +655,15 @@ class Server(object):
             else:
                 raise NbiException("Method {} not allowed".format(method), HTTPStatus.METHOD_NOT_ALLOWED)
             return self._format_out(outdata, session, _format)
-        except (NbiException, EngineException, DbException, FsException) as e:
+        except (NbiException, EngineException, DbException, FsException, MsgException) as e:
             if hasattr(outdata, "close"):  # is an open file
                 outdata.close()
             cherrypy.log("Exception {}".format(e))
             cherrypy.response.status = e.http_code.value
+            error_text = str(e)
+            if isinstance(e, MsgException):
+                error_text = "{} has been '{}' but other modules cannot be informed because an error on bus".format(
+                    engine_item[:-1], method, error_text)
             problem_details = {
                 "code": e.http_code.name,
                 "status": e.http_code.value,
index 734d667..004da02 100755 (executable)
@@ -54,16 +54,41 @@ headers_zip = {
 }
 # test without authorization
 test_not_authorized_list = (
-    ("Invalid token", "GET", "/admin/v1/users", headers_json, None, 401, r_header_json, "json"),
-    ("Invalid URL", "POST", "/admin/v1/nonexist", headers_yaml, None, 405, r_header_yaml, "yaml"),
-    ("Invalid version", "DELETE", "/admin/v2/users", headers_yaml, None, 405, r_header_yaml, "yaml"),
+    ("NA1", "Invalid token", "GET", "/admin/v1/users", headers_json, None, 401, r_header_json, "json"),
+    ("NA2", "Invalid URL", "POST", "/admin/v1/nonexist", headers_yaml, None, 405, r_header_yaml, "yaml"),
+    ("NA3", "Invalid version", "DELETE", "/admin/v2/users", headers_yaml, None, 405, r_header_yaml, "yaml"),
 )
 
 # test ones authorized
 test_authorized_list = (
-    ("Invalid vnfd id", "GET", "/vnfpkgm/v1/vnf_packages/non-existing-id", headers_json, None, 404, r_header_json, "json"),
-    ("Invalid nsd id", "GET", "/nsd/v1/ns_descriptors/non-existing-id", headers_yaml, None, 404, r_header_yaml, "yaml"),
-    ("Invalid nsd id", "DELETE", "/nsd/v1/ns_descriptors_content/non-existing-id", headers_yaml, None, 404, r_header_yaml, "yaml"),
+    ("AU1", "Invalid vnfd id", "GET", "/vnfpkgm/v1/vnf_packages/non-existing-id", headers_json, None, 404, r_header_json, "json"),
+    ("AU2","Invalid nsd id", "GET", "/nsd/v1/ns_descriptors/non-existing-id", headers_yaml, None, 404, r_header_yaml, "yaml"),
+    ("AU3","Invalid nsd id", "DELETE", "/nsd/v1/ns_descriptors_content/non-existing-id", headers_yaml, None, 404, r_header_yaml, "yaml"),
+)
+
+vim = {
+    "schema_version": "1.0",
+    "schema_type": "No idea",
+    "name": "myVim",
+    "description": "Descriptor name",
+    "vim_type": "openstack",
+    "vim_url": "http://localhost:/vim",
+    "vim_tenant_name": "vimTenant",
+    "vim_user": "user",
+    "vim_password": "password",
+    "config": {"config_param": 1}
+}
+
+vim_bad = vim.copy()
+vim_bad.pop("name")
+
+test_admin_list1 = (
+    ("VIM1", "Create VIM", "POST", "/admin/v1/vims", headers_json, vim, (201, 204), {"Location": "/admin/v1/vims/", "Content-Type": "application/json"}, "json"),
+    ("VIM2", "Create VIM bad schema", "POST", "/admin/v1/vims", headers_json, vim_bad, 422, None, headers_json),
+    ("VIM2", "Create VIM name repeated", "POST", "/admin/v1/vims", headers_json, vim, 409, None, headers_json),
+    ("VIM4", "Show VIMs", "GET", "/admin/v1/vims", headers_yaml, None, 200, r_header_yaml, "yaml"),
+    ("VIM5", "Show VIM", "GET", "/admin/v1/vims/{VIM1}", headers_yaml, None, 200, r_header_yaml, "yaml"),
+    ("VIM6", "Delete VIM", "DELETE", "/admin/v1/vims/{VIM1}", headers_yaml, None, 204, None, 0),
 )
 
 class TestException(Exception):
@@ -77,14 +102,18 @@ class TestRest:
         self.s = requests.session()
         self.s.headers = header_base
         self.verify = verify
+        # contains ID of tests obtained from Location response header. "" key contains last obtained id
+        self.test_ids = {}
 
     def set_header(self, header):
         self.s.headers.update(header)
 
-    def test(self, name, method, url, headers, payload, expected_codes, expected_headers, expected_payload):
+    def test(self, name, description, method, url, headers, payload, expected_codes, expected_headers, expected_payload):
         """
-        Performs an http request and check http code response. Exit if different than allowed
-        :param name:  name of the test
+        Performs an http request and check http code response. Exit if different than allowed. It get the returned id
+        that can be used by following test in the URL with {name} where name is the name of the test
+        :param name:  short name of the test
+        :param description:  description of the test
         :param method: HTTP method: GET,PUT,POST,DELETE,...
         :param url: complete URL or relative URL
         :param headers: request headers to add to the base headers
@@ -92,15 +121,27 @@ class TestRest:
         :param expected_codes: expected response codes, can be int, int tuple or int range
         :param expected_headers: expected response headers, dict with key values
         :param expected_payload: expected payload, 0 if empty, 'yaml', 'json', 'text', 'zip'
-        :return:
+        :return: requests response
         """
         try:
             if not self.s:
                 self.s = requests.session()
+            # URL
             if not url:
                 url = self.url_base
             elif not url.startswith("http"):
                 url = self.url_base + url
+
+            var_start = url.find("{") + 1
+            while var_start:
+                var_end = url.find("}", var_start)
+                if var_end == -1:
+                    break
+                var_name = url[var_start:var_end]
+                if var_name in self.test_ids:
+                    url = url[:var_start-1] + self.test_ids[var_name] + url[var_end+1:]
+                    var_start += len(self.test_ids[var_name])
+                var_start = url.find("{", var_start) + 1
             if payload:
                 if isinstance(payload, str):
                     if payload.startswith("@"):
@@ -114,7 +155,7 @@ class TestRest:
                 elif isinstance(payload, dict):
                     payload = json.dumps(payload)
     
-            test = "Test {} {} {}".format(name, method, url)
+            test = "Test {} {} {} {}".format(name, description, method, url)
             logger.warning(test)
             stream = False
             # if expected_payload == "zip":
@@ -165,6 +206,12 @@ class TestRest:
                     if len(r.content) == 0:
                         raise TestException("Expected some response payload, but got empty")
                     #r.text
+            location = r.headers.get("Location")
+            if location:
+                _id = location[location.rfind("/") + 1:]
+                if _id:
+                    self.test_ids[name] = str(_id)
+                    self.test_ids[""] = str(_id)  # last id
             return r
         except TestException as e:
             logger.error("{} \nRX code{}: {}".format(e, r.status_code, r.text))
@@ -177,6 +224,9 @@ class TestRest:
 if __name__ == "__main__":
     global logger
     test = ""
+
+    # Disable warnings from self-signed certificates.
+    requests.packages.urllib3.disable_warnings()
     try:
         logging.basicConfig(format="%(levelname)s %(message)s", level=logging.ERROR)
         logger = logging.getLogger('NBI')
@@ -225,7 +275,7 @@ if __name__ == "__main__":
             test_rest.test(*t)
 
         # get token
-        r = test_rest.test("Obtain token", "POST", "/admin/v1/tokens", headers_json,
+        r = test_rest.test("token1", "Obtain token", "POST", "/admin/v1/tokens", headers_json,
                            {"username": user, "password": password, "project_id": project},
                            (200, 201), {"Content-Type": "application/json"}, "json")
         response = r.json()
@@ -236,23 +286,28 @@ if __name__ == "__main__":
         for t in test_authorized_list:
             test_rest.test(*t)
 
+        # tests admin
+        for t in test_admin_list1:
+            test_rest.test(*t)
+
+
         # nsd CREATE
-        r = test_rest.test("Onboard NSD step 1", "POST", "/nsd/v1/ns_descriptors", headers_json, None,
+        r = test_rest.test("NSD1", "Onboard NSD step 1", "POST", "/nsd/v1/ns_descriptors", headers_json, None,
                            201, {"Location": "/nsd/v1/ns_descriptors/", "Content-Type": "application/json"}, "json")
         location = r.headers["Location"]
         nsd_id = location[location.rfind("/")+1:]
         # print(location, nsd_id)
 
         # nsd UPLOAD test
-        r = test_rest.test("Onboard NSD step 2 as TEXT", "PUT", "/nsd/v1/ns_descriptors/{}/nsd_content".format(nsd_id),
+        r = test_rest.test("NSD2", "Onboard NSD step 2 as TEXT", "PUT", "/nsd/v1/ns_descriptors/{}/nsd_content".format(nsd_id),
                            r_header_text, "@./cirros_ns/cirros_nsd.yaml", 204, None, 0)
 
         # nsd SHOW OSM format
-        r = test_rest.test("Show NSD OSM format", "GET", "/nsd/v1/ns_descriptors_content/{}".format(nsd_id),
+        r = test_rest.test("NSD3", "Show NSD OSM format", "GET", "/nsd/v1/ns_descriptors_content/{}".format(nsd_id),
                            headers_json, None, 200, r_header_json, "json")
 
         # nsd SHOW text
-        r = test_rest.test("Show NSD SOL005 text", "GET", "/nsd/v1/ns_descriptors/{}/nsd_content".format(nsd_id),
+        r = test_rest.test("NSD4", "Show NSD SOL005 text", "GET", "/nsd/v1/ns_descriptors/{}/nsd_content".format(nsd_id),
                            headers_text, None, 200, r_header_text, "text")
 
         # nsd UPLOAD ZIP
@@ -260,45 +315,45 @@ if __name__ == "__main__":
         tar = tarfile.open("temp/cirros_ns.tar.gz", "w:gz")
         tar.add("cirros_ns")
         tar.close()
-        r = test_rest.test("Onboard NSD step 3 replace with ZIP", "PUT", "/nsd/v1/ns_descriptors/{}/nsd_content".format(nsd_id),
+        r = test_rest.test("NSD5", "Onboard NSD step 3 replace with ZIP", "PUT", "/nsd/v1/ns_descriptors/{}/nsd_content".format(nsd_id),
                            r_header_zip, "@b./temp/cirros_ns.tar.gz", 204, None, 0)
 
         # nsd SHOW OSM format
-        r = test_rest.test("Show NSD OSM format", "GET", "/nsd/v1/ns_descriptors_content/{}".format(nsd_id),
+        r = test_rest.test("NSD6", "Show NSD OSM format", "GET", "/nsd/v1/ns_descriptors_content/{}".format(nsd_id),
                            headers_json, None, 200, r_header_json, "json")
 
         # nsd SHOW zip
-        r = test_rest.test("Show NSD SOL005 zip", "GET", "/nsd/v1/ns_descriptors/{}/nsd_content".format(nsd_id),
+        r = test_rest.test("NSD7", "Show NSD SOL005 zip", "GET", "/nsd/v1/ns_descriptors/{}/nsd_content".format(nsd_id),
                            headers_zip, None, 200, r_header_zip, "zip")
 
         # nsd SHOW descriptor
-        r = test_rest.test("Show NSD descriptor", "GET", "/nsd/v1/ns_descriptors/{}/nsd".format(nsd_id),
+        r = test_rest.test("NSD8", "Show NSD descriptor", "GET", "/nsd/v1/ns_descriptors/{}/nsd".format(nsd_id),
                            headers_text, None, 200, r_header_text, "text")
         # nsd SHOW actifact
-        r = test_rest.test("Show NSD artifact", "GET", "/nsd/v1/ns_descriptors/{}/artifacts/icons/osm_2x.png".format(nsd_id),
+        r = test_rest.test("NSD9", "Show NSD artifact", "GET", "/nsd/v1/ns_descriptors/{}/artifacts/icons/osm_2x.png".format(nsd_id),
                            headers_text, None, 200, r_header_octect, "text")
 
         # nsd DELETE
-        r = test_rest.test("Delete NSD SOL005 text", "DELETE", "/nsd/v1/ns_descriptors/{}".format(nsd_id),
+        r = test_rest.test("NSD10", "Delete NSD SOL005 text", "DELETE", "/nsd/v1/ns_descriptors/{}".format(nsd_id),
                            headers_yaml, None, 204, None, 0)
 
         # vnfd CREATE
-        r = test_rest.test("Onboard VNFD step 1", "POST", "/vnfpkgm/v1/vnf_packages", headers_json, None,
+        r = test_rest.test("VNFD1", "Onboard VNFD step 1", "POST", "/vnfpkgm/v1/vnf_packages", headers_json, None,
                            201, {"Location": "/vnfpkgm/v1/vnf_packages/", "Content-Type": "application/json"}, "json")
         location = r.headers["Location"]
         vnfd_id = location[location.rfind("/")+1:]
         # print(location, vnfd_id)
 
         # vnfd UPLOAD test
-        r = test_rest.test("Onboard VNFD step 2 as TEXT", "PUT", "/vnfpkgm/v1/vnf_packages/{}/package_content".format(vnfd_id),
+        r = test_rest.test("VNFD2", "Onboard VNFD step 2 as TEXT", "PUT", "/vnfpkgm/v1/vnf_packages/{}/package_content".format(vnfd_id),
                            r_header_text, "@./cirros_vnf/cirros_vnfd.yaml", 204, None, 0)
 
         # vnfd SHOW OSM format
-        r = test_rest.test("Show VNFD OSM format", "GET", "/vnfpkgm/v1/vnf_packages_content/{}".format(vnfd_id),
+        r = test_rest.test("VNFD3", "Show VNFD OSM format", "GET", "/vnfpkgm/v1/vnf_packages_content/{}".format(vnfd_id),
                            headers_json, None, 200, r_header_json, "json")
 
         # vnfd SHOW text
-        r = test_rest.test("Show VNFD SOL005 text", "GET", "/vnfpkgm/v1/vnf_packages/{}/package_content".format(vnfd_id),
+        r = test_rest.test("VNFD4", "Show VNFD SOL005 text", "GET", "/vnfpkgm/v1/vnf_packages/{}/package_content".format(vnfd_id),
                            headers_text, None, 200, r_header_text, "text")
 
         # vnfd UPLOAD ZIP
@@ -306,25 +361,25 @@ if __name__ == "__main__":
         tar = tarfile.open("temp/cirros_vnf.tar.gz", "w:gz")
         tar.add("cirros_vnf")
         tar.close()
-        r = test_rest.test("Onboard VNFD step 3 replace with ZIP", "PUT", "/vnfpkgm/v1/vnf_packages/{}/package_content".format(vnfd_id),
+        r = test_rest.test("VNFD5", "Onboard VNFD step 3 replace with ZIP", "PUT", "/vnfpkgm/v1/vnf_packages/{}/package_content".format(vnfd_id),
                            r_header_zip, "@b./temp/cirros_vnf.tar.gz", 204, None, 0)
 
         # vnfd SHOW OSM format
-        r = test_rest.test("Show VNFD OSM format", "GET", "/vnfpkgm/v1/vnf_packages_content/{}".format(vnfd_id),
+        r = test_rest.test("VNFD6", "Show VNFD OSM format", "GET", "/vnfpkgm/v1/vnf_packages_content/{}".format(vnfd_id),
                            headers_json, None, 200, r_header_json, "json")
 
         # vnfd SHOW zip
-        r = test_rest.test("Show VNFD SOL005 zip", "GET", "/vnfpkgm/v1/vnf_packages/{}/package_content".format(vnfd_id),
+        r = test_rest.test("VNFD7", "Show VNFD SOL005 zip", "GET", "/vnfpkgm/v1/vnf_packages/{}/package_content".format(vnfd_id),
                            headers_zip, None, 200, r_header_zip, "zip")
         # vnfd SHOW descriptor
-        r = test_rest.test("Show VNFD descriptor", "GET", "/vnfpkgm/v1/vnf_packages/{}/vnfd".format(vnfd_id),
+        r = test_rest.test("VNFD8", "Show VNFD descriptor", "GET", "/vnfpkgm/v1/vnf_packages/{}/vnfd".format(vnfd_id),
                            headers_text, None, 200, r_header_text, "text")
         # vnfd SHOW actifact
-        r = test_rest.test("Show VNFD artifact", "GET", "/vnfpkgm/v1/vnf_packages/{}/artifacts/icons/cirros-64.png".format(vnfd_id),
+        r = test_rest.test("VNFD9", "Show VNFD artifact", "GET", "/vnfpkgm/v1/vnf_packages/{}/artifacts/icons/cirros-64.png".format(vnfd_id),
                            headers_text, None, 200, r_header_octect, "text")
 
         # vnfd DELETE
-        r = test_rest.test("Delete VNFD SOL005 text", "DELETE", "/vnfpkgm/v1/vnf_packages/{}".format(vnfd_id),
+        r = test_rest.test("VNFD10", "Delete VNFD SOL005 text", "DELETE", "/vnfpkgm/v1/vnf_packages/{}".format(vnfd_id),
                            headers_yaml, None, 204, None, 0)
 
         print("PASS")
diff --git a/osm_nbi/validation.py b/osm_nbi/validation.py
new file mode 100644 (file)
index 0000000..2419c1a
--- /dev/null
@@ -0,0 +1,190 @@
+# -*- coding: utf-8 -*-
+
+from jsonschema import validate as js_v, exceptions as js_e
+
+__author__ = "Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>"
+__version__ = "0.1"
+version_date = "Mar 2018"
+
+"""
+Validator of input data using JSON schemas for those items that not contains an  OSM yang information model
+"""
+
+# Basis schemas
+patern_name = "^[ -~]+$"
+passwd_schema = {"type": "string", "minLength": 1, "maxLength": 60}
+nameshort_schema = {"type": "string", "minLength": 1, "maxLength": 60, "pattern": "^[^,;()'\"]+$"}
+name_schema = {"type": "string", "minLength": 1, "maxLength": 255, "pattern": "^[^,;()'\"]+$"}
+xml_text_schema = {"type": "string", "minLength": 1, "maxLength": 1000, "pattern": "^[^']+$"}
+description_schema = {"type": ["string", "null"], "maxLength": 255, "pattern": "^[^'\"]+$"}
+id_schema_fake = {"type": "string", "minLength": 2,
+                  "maxLength": 36}  # "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$"
+id_schema = {"type": "string", "pattern": "^[a-fA-F0-9]{8}(-[a-fA-F0-9]{4}){3}-[a-fA-F0-9]{12}$"}
+pci_schema = {"type": "string", "pattern": "^[0-9a-fA-F]{4}(:[0-9a-fA-F]{2}){2}\.[0-9a-fA-F]$"}
+http_schema = {"type": "string", "pattern": "^https?://[^'\"=]+$"}
+bandwidth_schema = {"type": "string", "pattern": "^[0-9]+ *([MG]bps)?$"}
+memory_schema = {"type": "string", "pattern": "^[0-9]+ *([MG]i?[Bb])?$"}
+integer0_schema = {"type": "integer", "minimum": 0}
+integer1_schema = {"type": "integer", "minimum": 1}
+path_schema = {"type": "string", "pattern": "^(\.){0,2}(/[^/\"':{}\(\)]+)+$"}
+vlan_schema = {"type": "integer", "minimum": 1, "maximum": 4095}
+vlan1000_schema = {"type": "integer", "minimum": 1000, "maximum": 4095}
+mac_schema = {"type": "string",
+              "pattern": "^[0-9a-fA-F][02468aceACE](:[0-9a-fA-F]{2}){5}$"}  # must be unicast: LSB bit of MSB byte ==0
+# mac_schema={"type":"string", "pattern":"^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$"}
+ip_schema = {"type": "string",
+             "pattern": "^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$"}
+ip_prefix_schema = {"type": "string",
+                    "pattern": "^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)/(30|[12]?[0-9])$"}
+port_schema = {"type": "integer", "minimum": 1, "maximum": 65534}
+object_schema = {"type": "object"}
+schema_version_2 = {"type": "integer", "minimum": 2, "maximum": 2}
+# schema_version_string={"type":"string","enum": ["0.1", "2", "0.2", "3", "0.3"]}
+log_level_schema = {"type": "string", "enum": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]}
+checksum_schema = {"type": "string", "pattern": "^[0-9a-fA-F]{32}$"}
+size_schema = {"type": "integer", "minimum": 1, "maximum": 100}
+
+schema_version = {"type": "string", "enum": ["1.0"]}
+schema_type = {"type": "string"}
+
+vim_new_schema = {
+    "title": "vims new user input schema",
+    "$schema": "http://json-schema.org/draft-04/schema#",
+    "type": "object",
+    "properties": {
+        "schema_version": schema_version,
+        "schema_type": schema_type,
+        "name": name_schema,
+        "description": description_schema,
+        "vim_type": {"enum": ["openstack", "openvim", "vmware", "opennebula", "aws"]},
+        "vim_url": description_schema,
+        # "vim_url_admin": description_schema,
+        # "vim_tenant": name_schema,
+        "vim_tenant_name": name_schema,
+        "vim_user": nameshort_schema,
+        "vim_password": nameshort_schema,
+        "config": {"type": "object"}
+    },
+    "required": ["name", "vim_url", "vim_type", "vim_user", "vim_password", "vim_tenant_name"],
+    "additionalProperties": False
+}
+vim_edit_schema = {
+    "title": "datacenter edit nformation schema",
+    "$schema": "http://json-schema.org/draft-04/schema#",
+    "type": "object",
+    "properties": {
+        "name": name_schema,
+        "description": description_schema,
+        "type": nameshort_schema,  # currently "openvim" or "openstack", can be enlarged with plugins
+        "vim_url": description_schema,
+        "vim_url_admin": description_schema,
+        "vim_tenant": name_schema,
+        "vim_tenant_name": name_schema,
+        "vim_username": nameshort_schema,
+        "vim_password": nameshort_schema,
+        "config": {"type": "object"}
+    },
+    "additionalProperties": False
+}
+
+
+sdn_properties = {
+    "name": name_schema,
+    "dpid": {"type": "string", "pattern": "^[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){7}$"},
+    "ip": ip_schema,
+    "port": port_schema,
+    "type": {"type": "string", "enum": ["opendaylight", "floodlight", "onos"]},
+    "version": {"type": "string", "minLength": 1, "maxLength": 12},
+    "user": nameshort_schema,
+    "password": passwd_schema
+}
+sdn_new_schema = {
+    "title": "sdn controller information schema",
+    "$schema": "http://json-schema.org/draft-04/schema#",
+    "type": "object",
+    "properties": sdn_properties,
+    "required": ["name", "port", 'ip', 'dpid', 'type'],
+    "additionalProperties": False
+}
+sdn_edit_schema = {
+    "title": "sdn controller update information schema",
+    "$schema": "http://json-schema.org/draft-04/schema#",
+    "type": "object",
+    "properties": sdn_properties,
+    "required": ["name", "port", 'ip', 'dpid', 'type'],
+    "additionalProperties": False
+}
+sdn_port_mapping_schema = {
+    "$schema": "http://json-schema.org/draft-04/schema#",
+    "title": "sdn port mapping information schema",
+    "type": "array",
+    "items": {
+        "type": "object",
+        "properties": {
+            "compute_node": nameshort_schema,
+            "ports": {
+                "type": "array",
+                "items": {
+                    "type": "object",
+                    "properties": {
+                        "pci": pci_schema,
+                        "switch_port": nameshort_schema,
+                        "switch_mac": mac_schema
+                    },
+                    "required": ["pci"]
+                }
+            }
+        },
+        "required": ["compute_node", "ports"]
+    }
+}
+sdn_external_port_schema = {
+    "$schema": "http://json-schema.org/draft-04/schema#",
+    "title": "External port ingformation",
+    "type": "object",
+    "properties": {
+        "port": {"type": "string", "minLength": 1, "maxLength": 60},
+        "vlan": vlan_schema,
+        "mac": mac_schema
+    },
+    "required": ["port"]
+}
+
+
+nbi_new_input_schemas = {
+    "vims": vim_new_schema,
+    "sdns": sdn_new_schema
+}
+
+nbi_edit_input_schemas = {
+    "vims": vim_edit_schema,
+    "sdns": sdn_edit_schema
+}
+
+
+class ValidationError(Exception):
+    pass
+
+
+def validate_input(indata, item, new=True):
+    """
+    Validates input data agains json schema
+    :param indata: user input data. Should be a dictionary
+    :param item: can be users, projects, vims, sdns
+    :param new: True if the validation is for creating or False if it is for editing
+    :return: None if ok, raises ValidationError exception otherwise
+    """
+    try:
+        if new:
+            schema_to_use = nbi_new_input_schemas.get(item)
+        else:
+            schema_to_use = nbi_edit_input_schemas.get(item)
+        if schema_to_use:
+            js_v(indata, schema_to_use)
+        return None
+    except js_e.ValidationError as e:
+        if e.path:
+            error_pos = "at '" + ":".join(e.path) + "'"
+        else:
+            error_pos = ""
+        raise ValidationError("Format error {} '{}' ".format(error_pos, e))
index 60d7e28..96c9e49 100644 (file)
--- a/setup.py
+++ b/setup.py
@@ -23,7 +23,7 @@ setup(
                 ],
 
     install_requires=[
-        'CherryPy', 'pymongo'
+        'CherryPy', 'pymongo', 'jsonchema'
     ],
 #    setup_requires=['setuptools-version-command'],
     # test_suite='nose.collector',
index 0fbc1d6..72970ec 100644 (file)
--- a/stdeb.cfg
+++ b/stdeb.cfg
@@ -1,2 +1,2 @@
 [DEFAULT]
-Depends: python3-cherrypy3, python3-pymongo, python3-yaml
+Depends: python3-cherrypy3, python3-pymongo, python3-yaml, python3-jsonschema