CSIT python API introduction 27/15727/27
authorJan Gelety <jgelety@cisco.com>
Tue, 25 Sep 2018 13:41:10 +0000 (15:41 +0200)
committerJan Gelety <jgelety@cisco.com>
Tue, 27 Nov 2018 14:58:18 +0000 (14:58 +0000)
Jira: CSIT-1336

Change-Id: I96d2b0221c5a7466484a82339fc132c5921532d1
Signed-off-by: Jan Gelety <jgelety@cisco.com>
resources/libraries/bash/function/artifacts.sh
resources/libraries/python/PapiErrors.py [new file with mode: 0644]
resources/libraries/python/PapiExecutor.py [new file with mode: 0644]
resources/libraries/python/VPPUtil.py
resources/libraries/python/constants.py
resources/tools/papi/vpp_papi_provider.py [new file with mode: 0644]
tests/vpp/func/__init__.robot
tests/vpp/perf/__init__.robot

index abb0b5f..6695b4d 100644 (file)
@@ -64,7 +64,7 @@ function download_ubuntu_artifacts () {
     }
     # If version is set we will add suffix.
     artifacts=()
-    vpp=(vpp vpp-dbg vpp-dev vpp-lib vpp-plugins)
+    vpp=(vpp vpp-dbg vpp-dev vpp-lib vpp-plugins vpp-api-python)
     if [ -z "${VPP_VERSION-}" ]; then
         artifacts+=(${vpp[@]})
     else
@@ -97,7 +97,7 @@ function download_centos_artifacts () {
     }
     # If version is set we will add suffix.
     artifacts=()
-    vpp=(vpp vpp-selinux-policy vpp-devel vpp-lib vpp-plugins)
+    vpp=(vpp vpp-selinux-policy vpp-devel vpp-lib vpp-plugins vpp-api-python)
     if [ -z "${VPP_VERSION-}" ]; then
         artifacts+=(${vpp[@]})
     else
diff --git a/resources/libraries/python/PapiErrors.py b/resources/libraries/python/PapiErrors.py
new file mode 100644 (file)
index 0000000..5afebbf
--- /dev/null
@@ -0,0 +1,42 @@
+# Copyright (c) 2018 Cisco and/or its affiliates.
+# 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.
+
+"""PAPI Errors class file."""
+
+__all__ = ['PapiError', 'PapiInitError', 'PapiJsonFileError',
+           'PapiCommandError', 'PapiCommandInputError']
+
+
+class PapiError(Exception):
+    """Python API error."""
+    pass
+
+
+class PapiInitError(PapiError):
+    """This exception is raised when construction of VPP instance failed."""
+    pass
+
+
+class PapiJsonFileError(PapiError):
+    """This exception is raised in case of JSON API file error."""
+    pass
+
+
+class PapiCommandError(PapiError):
+    """This exception is raised when PAPI command(s) execution failed."""
+    pass
+
+
+class PapiCommandInputError(PapiCommandError):
+    """This exception is raised when incorrect input of Python API is used."""
+    pass
diff --git a/resources/libraries/python/PapiExecutor.py b/resources/libraries/python/PapiExecutor.py
new file mode 100644 (file)
index 0000000..6a47b94
--- /dev/null
@@ -0,0 +1,223 @@
+# Copyright (c) 2018 Cisco and/or its affiliates.
+# 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.
+
+"""Python API executor library."""
+
+import binascii
+import json
+
+from paramiko.ssh_exception import SSHException
+from robot.api import logger
+
+from resources.libraries.python.constants import Constants
+from resources.libraries.python.PapiErrors import PapiInitError, \
+    PapiJsonFileError, PapiCommandError, PapiCommandInputError
+# TODO: from resources.libraries.python.PapiHistory import PapiHistory
+from resources.libraries.python.ssh import SSH, SSHTimeout
+
+__all__ = ['PapiExecutor']
+
+
+class PapiExecutor(object):
+    """Contains methods for executing Python API commands on DUTs."""
+
+    def __init__(self, node):
+        self._stdout = None
+        self._stderr = None
+        self._ret_code = None
+        self._node = node
+        self._json_data = None
+        self._api_reply = list()
+        self._api_data = None
+
+        self._ssh = SSH()
+        try:
+            self._ssh.connect(node)
+        except:
+            raise SSHException('Cannot open SSH connection to host {host} to '
+                               'execute PAPI command(s)'.
+                               format(host=self._node['host']))
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        pass
+
+    @staticmethod
+    def _process_api_data(api_d):
+        """Process API data for smooth converting to JSON string.
+
+        Apply binascii.hexlify() method for string values.
+
+        :param api_d: List of APIs with their arguments.
+        :type api_d: list
+        :returns: List of APIs with arguments pre-processed for JSON.
+        :rtype: list
+        """
+
+        api_data_processed = list()
+        for api in api_d:
+            api_name = api['api_name']
+            api_args = api['api_args']
+            api_processed = dict(api_name=api_name)
+            api_args_processed = dict()
+            for a_k, a_v in api_args.iteritems():
+                value = binascii.hexlify(a_v) if isinstance(a_v, str) else a_v
+                api_args_processed[str(a_k)] = value
+            api_processed['api_args'] = api_args_processed
+            api_data_processed.append(api_processed)
+        return api_data_processed
+
+    @staticmethod
+    def _revert_api_reply(api_r):
+        """Process API reply / a part of API reply.
+
+        Apply binascii.unhexlify() method for unicode values.
+
+        :param api_r: API reply.
+        :type api_r: dict
+        :returns: Processed API reply / a part of API reply.
+        :rtype: dict
+        """
+
+        reply_dict = dict()
+        reply_value = dict()
+        for reply_key, reply_v in api_r.iteritems():
+            for a_k, a_v in reply_v.iteritems():
+                value = binascii.unhexlify(a_v) if isinstance(a_v, unicode) \
+                    else a_v
+                reply_value[a_k] = value
+            reply_dict[reply_key] = reply_value
+        return reply_dict
+
+    def _process_reply(self, api_reply):
+        """Process API reply.
+
+        :param api_reply: API reply.
+        :type api_reply: dict or list of dict
+        :returns: Processed API reply.
+        :rtype: list or dict
+        """
+
+        if isinstance(api_reply, list):
+            reverted_reply = list()
+            for a_r in api_reply:
+                reverted_reply.append(self._revert_api_reply(a_r))
+        else:
+            reverted_reply = self._revert_api_reply(api_reply)
+        return reverted_reply
+
+    def _process_json_data(self):
+        """Process received JSON data."""
+
+        for data in self._json_data:
+            api_name = data['api_name']
+            api_reply = data['api_reply']
+            api_reply_processed = dict(
+                api_name=api_name, api_reply=self._process_reply(api_reply))
+            self._api_reply.append(api_reply_processed)
+
+    def execute_papi(self, api_data, timeout=120):
+        """Execute PAPI command(s) on remote node and store the result.
+
+        :param api_data: List of APIs with their arguments.
+        :param timeout: Timeout in seconds.
+        :type api_data: list
+        :type timeout: int
+        :raises SSHTimeout: If PAPI command(s) execution is timed out.
+        :raises PapiInitError: If PAPI initialization failed.
+        :raises PapiJsonFileError: If no api.json file found.
+        :raises PapiCommandError: If PAPI command(s) execution failed.
+        :raises PapiCommandInputError: If invalid attribute name or invalid
+            value is used in API call.
+        :raises RuntimeError: If PAPI executor failed due to another reason.
+        """
+        self._api_data = api_data
+        api_data_processed = self._process_api_data(api_data)
+        json_data = json.dumps(api_data_processed)
+
+        cmd = "python {fw_dir}/{papi_provider} --json_data '{json}'".format(
+            fw_dir=Constants.REMOTE_FW_DIR,
+            papi_provider=Constants.RESOURCES_PAPI_PROVIDER,
+            json=json_data)
+
+        try:
+            ret_code, stdout, stderr = self._ssh.exec_command_sudo(
+                cmd=cmd, timeout=timeout)
+        except SSHTimeout:
+            logger.error('PAPI command(s) execution timeout on host {host}:'
+                         '\n{apis}'.format(host=self._node['host'],
+                                           apis=self._api_data))
+            raise
+        except (PapiInitError, PapiJsonFileError, PapiCommandError,
+                PapiCommandInputError):
+            logger.error('PAPI command(s) execution failed on host {host}'.
+                         format(host=self._node['host']))
+            raise
+        except:
+            raise RuntimeError('PAPI command(s) execution on host {host} '
+                               'failed: {apis}'.format(host=self._node['host'],
+                                                       apis=self._api_data))
+
+        self._ret_code = ret_code
+        self._stdout = stdout
+        self._stderr = stderr
+
+    def papi_should_have_failed(self):
+        """Read return code from last executed script and raise exception if the
+        PAPI command(s) didn't fail.
+
+        :raises RuntimeError: When no PAPI command executed.
+        :raises AssertionError: If PAPI command(s) execution passed.
+        """
+
+        if self._ret_code is None:
+            raise RuntimeError("First execute the PAPI command(s)!")
+        if self._ret_code == 0:
+            raise AssertionError(
+                "PAPI command(s) execution passed, but failure was expected: "
+                "{apis}".format(apis=self._api_data))
+
+    def papi_should_have_passed(self):
+        """Read return code from last executed script and raise exception if the
+        PAPI command(s) failed.
+
+        :raises RuntimeError: When no PAPI command executed.
+        :raises AssertionError: If PAPI command(s) execution failed.
+        """
+
+        if self._ret_code is None:
+            raise RuntimeError("First execute the PAPI command(s)!")
+        if self._ret_code != 0:
+            raise AssertionError(
+                "PAPI command(s) execution failed, but success was expected: "
+                "{apis}".format(apis=self._api_data))
+
+    def get_papi_stdout(self):
+        """Returns value of stdout from last executed PAPI command(s)."""
+
+        return self._stdout
+
+    def get_papi_stderr(self):
+        """Returns value of stderr from last executed PAPI command(s)."""
+
+        return self._stderr
+
+    def get_papi_reply(self):
+        """Returns api reply from last executed PAPI command(s)."""
+
+        self._json_data = json.loads(self._stdout)
+        self._process_json_data()
+
+        return self._api_reply
index 3714d3b..d6c02a3 100644 (file)
 
 import time
 
+from robot.api import logger
+
 from resources.libraries.python.constants import Constants
 from resources.libraries.python.DUTSetup import DUTSetup
+from resources.libraries.python.PapiExecutor import PapiExecutor
+from resources.libraries.python.PapiErrors import PapiError
 from resources.libraries.python.ssh import exec_cmd, exec_cmd_no_error
 from resources.libraries.python.topology import NodeType
 from resources.libraries.python.VatExecutor import VatExecutor
@@ -135,24 +139,64 @@ class VPPUtil(object):
                 VPPUtil.verify_vpp_on_dut(node)
 
     @staticmethod
-    def vpp_show_version_verbose(node):
-        """Run "show version verbose" CLI command.
+    def vpp_show_version(node, verbose=False):
+        """Run "show_version" API command.
 
         :param node: Node to run command on.
+        :param verbose: Show version, compile date and compile location if True
+            otherwise show only version.
         :type node: dict
+        :type verbose: bool
+        :raises PapiError: If no reply received for show_version API command.
         """
-        vat = VatExecutor()
-        vat.execute_script("show_version_verbose.vat", node, json_out=False)
+        # TODO: move composition of api data to separate method
+        api_data = list()
+        api = dict(api_name='show_version')
+        api_args = dict()
+        api['api_args'] = api_args
+        api_data.append(api)
+
+        api_reply = None
+        with PapiExecutor(node) as papi_executor:
+            papi_executor.execute_papi(api_data)
+            try:
+                papi_executor.papi_should_have_passed()
+            except AssertionError:
+                raise RuntimeError('Failed to get VPP version on host: {host}'.
+                                   format(host=node['host']))
+            api_reply = papi_executor.get_papi_reply()
+
+        if api_reply is not None:
+            version_data = api_reply[0]['api_reply']['show_version_reply']
+            ver = version_data['version'].rstrip('\0x00')
+            if verbose:
+                date = version_data['build_date'].rstrip('\0x00')
+                loc = version_data['build_directory'].rstrip('\0x00')
+                version = \
+                    'VPP Version:        {ver}\n' \
+                    'Compile date:       {date}\n' \
+                    'Compile location:   {loc}\n '\
+                    .format(ver=ver, date=date, loc=loc)
+            else:
+                version = 'VPP version:{ver}'.format(ver=ver)
+            logger.info(version)
+        else:
+            raise PapiError('No reply received for show_version API command on '
+                            'host {host}'.format(host=node['host']))
 
-        try:
-            vat.script_should_have_passed()
-        except AssertionError:
-            raise RuntimeError('Failed to get VPP version on host: {name}'.
-                               format(name=node['host']))
+    @staticmethod
+    def vpp_show_version_verbose(node):
+        """Run "show_version" API command and return verbose string of version
+        data.
+
+        :param node: Node to run command on.
+        :type node: dict
+        """
+        VPPUtil.vpp_show_version(node, verbose=True)
 
     @staticmethod
     def show_vpp_version_on_all_duts(nodes):
-        """Show VPP version verbose on all DUTs.
+        """Show VPP version on all DUTs.
 
         :param nodes: VPP nodes.
         :type nodes: dict
index a0a427a..43fbf1a 100644 (file)
@@ -23,6 +23,9 @@ class Constants(object):
     # shell scripts location
     RESOURCES_LIB_SH = 'resources/libraries/bash'
 
+    # Python API provider location
+    RESOURCES_PAPI_PROVIDER = 'resources/tools/papi/vpp_papi_provider.py'
+
     # vat templates location
     RESOURCES_TPL_VAT = 'resources/templates/vat'
 
diff --git a/resources/tools/papi/vpp_papi_provider.py b/resources/tools/papi/vpp_papi_provider.py
new file mode 100644 (file)
index 0000000..0d93497
--- /dev/null
@@ -0,0 +1,230 @@
+#!/usr/bin/env python
+
+# Copyright (c) 2018 Cisco and/or its affiliates.
+# 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.
+
+"""Python API provider.
+"""
+
+import argparse
+import binascii
+import fnmatch
+import json
+import os
+import sys
+
+sys.path.append('/tmp/openvpp-testing')
+try:
+    from resources.libraries.python.PapiErrors import *
+except:
+    raise
+
+# Sphinx creates auto-generated documentation by importing the python source
+# files and collecting the docstrings from them. The NO_VPP_PAPI flag allows
+# the vpp_papi_provider.py file to be importable without having to build
+# the whole vpp api if the user only wishes to generate the test documentation.
+do_import = True
+try:
+    no_vpp_papi = os.getenv("NO_VPP_PAPI")
+    if no_vpp_papi == "1":
+        do_import = False
+except:
+    pass
+
+if do_import:
+    # TODO: run os.walk once per whole suite and store the path in environmental
+    # variable
+    modules_path = None
+    for root, dirs, files in os.walk('/usr/lib'):
+        for name in files:
+            if name == 'vpp_papi.py':
+                modules_path = os.path.split(root)[0]
+                break
+    if modules_path:
+        sys.path.append(modules_path)
+        from vpp_papi import VPP
+    else:
+        raise PapiInitError('vpp_papi module not found')
+
+# client name
+CLIENT_NAME = 'csit_papi'
+
+
+def papi_init(vpp_json_dir='/usr/share/vpp/api/'):
+    """Construct a VPP instance from VPP JSON API files.
+
+    :param vpp_json_dir: Directory containing all the JSON API files. If VPP is
+        installed in the system it will be in /usr/share/vpp/api/.
+    :type vpp_json_dir: str
+    :returns: VPP instance.
+    :rtype: VPP object
+    :raises PapiJsonFileError: If no api.json file found.
+    :raises PapiInitError: If PAPI initialization failed.
+    """
+    # construct a list of all the json api files
+    jsonfiles = []
+    for root, dirnames, filenames in os.walk(vpp_json_dir):
+        for filename in fnmatch.filter(filenames, '*.api.json'):
+            jsonfiles.append(os.path.join(vpp_json_dir, filename))
+    if not jsonfiles:
+        raise PapiJsonFileError(
+            'No json api files found in location {dir}'.format(
+                dir=vpp_json_dir))
+
+    try:
+        vpp = VPP(jsonfiles)
+        return vpp
+    except Exception as err:
+        raise PapiInitError('PAPI init failed:\n{exc}'.format(exc=repr(err)))
+
+
+def papi_connect(vpp_client, name='vpp_api'):
+    """Attach to VPP client.
+
+    :param vpp_client: VPP instance to connect to.
+    :param name: VPP client name.
+    :type vpp_client: VPP object
+    :type name: str
+    :returns: Return code of VPP.connect() method.
+    :rtype: int
+    """
+    return vpp_client.connect(name)
+
+
+def papi_disconnect(vpp_client):
+    """Detach from VPP client.
+
+    :param vpp_client: VPP instance to detach from.
+    :type vpp_client: VPP object
+    """
+    vpp_client.disconnect()
+
+
+def papi_run(vpp_client, api_name, api_args):
+    """api_name
+
+    :param vpp_client: VPP instance.
+    :param api_name: VPP API name.
+    :param api_args: Input arguments of the API.
+    :type vpp_client: VPP object
+    :type api_name: str
+    :type api_args: dict
+    :returns: VPP API reply.
+    :rtype: Vpp_serializer reply object
+    """
+    papi_fn = getattr(vpp_client.api, api_name)
+    return papi_fn(**api_args)
+
+
+def convert_reply(api_r):
+    """Process API reply / a part of API reply for smooth converting to
+    JSON string.
+
+    Apply binascii.hexlify() method for string values.
+    :param api_r: API reply.
+    :type api_r: Vpp_serializer reply object (named tuple)
+    :returns: Processed API reply / a part of API reply.
+    :rtype: dict
+    """
+    unwanted_fields = ['count', 'index']
+
+    reply_dict = dict()
+    reply_key = repr(api_r).split('(')[0]
+    reply_value = dict()
+    for item in dir(api_r):
+        if not item.startswith('_') and item not in unwanted_fields:
+            attr_value = getattr(api_r, item)
+            value = binascii.hexlify(attr_value) \
+                if isinstance(attr_value, str) else attr_value
+            reply_value[item] = value
+    reply_dict[reply_key] = reply_value
+    return reply_dict
+
+
+def process_reply(api_reply):
+    """Process API reply for smooth converting to JSON string.
+
+    :param api_reply: API reply.
+    :type api_reply: Vpp_serializer reply object (named tuple) or list of
+        vpp_serializer reply objects
+    :returns: Processed API reply.
+    :rtype: list or dict
+    """
+
+    if isinstance(api_reply, list):
+        converted_reply = list()
+        for r in api_reply:
+            converted_reply.append(convert_reply(r))
+    else:
+        converted_reply = convert_reply(api_reply)
+    return converted_reply
+
+
+def main():
+    """Main function for the Python API provider.
+
+    :raises PapiCommandInputError: If invalid attribute name or invalid value is
+        used in API call.
+    :raises PapiCommandError: If PAPI command(s) execution failed.
+    """
+
+    parser = argparse.ArgumentParser()
+    parser.add_argument("-j", "--json_data",
+                        required=True,
+                        type=str,
+                        help="JSON string (list) containing API name(s) and "
+                             "its/their input argument(s).")
+    parser.add_argument("-d", "--json_dir",
+                        type=str,
+                        default='/usr/share/vpp/api/',
+                        help="Directory containing all vpp json api files.")
+    args = parser.parse_args()
+    json_string = args.json_data
+    vpp_json_dir = args.json_dir
+
+    vpp = papi_init(vpp_json_dir=vpp_json_dir)
+
+    reply = list()
+    json_data = json.loads(json_string)
+    papi_connect(vpp, CLIENT_NAME)
+    for data in json_data:
+        api_name = data['api_name']
+        api_args_unicode = data['api_args']
+        api_reply = dict(api_name=api_name)
+        api_args = dict()
+        for a_k, a_v in api_args_unicode.iteritems():
+            value = binascii.unhexlify(a_v) if isinstance(a_v, unicode) else a_v
+            api_args[str(a_k)] = value
+        try:
+            rep = papi_run(vpp, api_name, api_args)
+            api_reply['api_reply'] = process_reply(rep)
+            reply.append(api_reply)
+        except (AttributeError, ValueError) as err:
+            papi_disconnect(vpp)
+            raise PapiCommandInputError(
+                'PAPI command {api}({args}) input error:\n{exc}'.format(
+                    api=api_name, args=api_args), exc=repr(err))
+        except Exception as err:
+            papi_disconnect(vpp)
+            raise PapiCommandError(
+                'PAPI command {api}({args}) error:\n{exc}'.format(
+                    api=api_name, args=api_args), exc=repr(err))
+    papi_disconnect(vpp)
+
+    return json.dumps(reply)
+
+
+if __name__ == '__main__':
+    sys.stdout.write(main())
+    sys.stdout.flush()
+    sys.exit(0)
index 9243257..b074d0d 100644 (file)
@@ -35,7 +35,8 @@
 | | ...
 | | Set Global Variable | ${VPP_PKG_DIR} | /scratch/vpp/
 | | @{VPP_RPM_PKGS}= | Create List | vpp | vpp-devel | vpp-lib | vpp-plugins
+| | ... | vpp-api-python
 | | Set Global Variable | ${VPP_RPM_PKGS}
 | | @{VPP_DEB_PKGS}= | Create List | vpp | vpp-dbg | vpp-dev | vpp-lib
-| | ... | vpp-plugins
+| | ... | vpp-plugins | vpp-api-python
 | | Set Global Variable | ${VPP_DEB_PKGS}
index bba10eb..0e24ecd 100644 (file)
@@ -60,5 +60,5 @@
 | | @{vpp_rpm_pkgs}= | Create List | vpp | vpp-devel | vpp-lib | vpp-plugins
 | | Set Global Variable | ${vpp_rpm_pkgs}
 | | @{vpp_deb_pkgs}= | Create List | vpp | vpp-dbg | vpp-dev | vpp-lib
-| | ... | vpp-plugins
+| | ... | vpp-plugins | vpp-api-python
 | | Set Global Variable | ${vpp_deb_pkgs}