From 287406e3097d8409bcf12cba3eb3304f91857e90 Mon Sep 17 00:00:00 2001 From: Jan Gelety Date: Tue, 25 Sep 2018 15:41:10 +0200 Subject: [PATCH 1/1] CSIT python API introduction Jira: CSIT-1336 Change-Id: I96d2b0221c5a7466484a82339fc132c5921532d1 Signed-off-by: Jan Gelety --- resources/libraries/bash/function/artifacts.sh | 4 +- resources/libraries/python/PapiErrors.py | 42 +++++ resources/libraries/python/PapiExecutor.py | 223 ++++++++++++++++++++++++ resources/libraries/python/VPPUtil.py | 64 +++++-- resources/libraries/python/constants.py | 3 + resources/tools/papi/vpp_papi_provider.py | 230 +++++++++++++++++++++++++ tests/vpp/func/__init__.robot | 3 +- tests/vpp/perf/__init__.robot | 2 +- 8 files changed, 557 insertions(+), 14 deletions(-) create mode 100644 resources/libraries/python/PapiErrors.py create mode 100644 resources/libraries/python/PapiExecutor.py create mode 100644 resources/tools/papi/vpp_papi_provider.py diff --git a/resources/libraries/bash/function/artifacts.sh b/resources/libraries/bash/function/artifacts.sh index abb0b5f428..6695b4d977 100644 --- a/resources/libraries/bash/function/artifacts.sh +++ b/resources/libraries/bash/function/artifacts.sh @@ -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 index 0000000000..5afebbf2ce --- /dev/null +++ b/resources/libraries/python/PapiErrors.py @@ -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 index 0000000000..6a47b9497f --- /dev/null +++ b/resources/libraries/python/PapiExecutor.py @@ -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 diff --git a/resources/libraries/python/VPPUtil.py b/resources/libraries/python/VPPUtil.py index 3714d3b780..d6c02a3e8a 100644 --- a/resources/libraries/python/VPPUtil.py +++ b/resources/libraries/python/VPPUtil.py @@ -15,8 +15,12 @@ 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 diff --git a/resources/libraries/python/constants.py b/resources/libraries/python/constants.py index a0a427af4e..43fbf1a76d 100644 --- a/resources/libraries/python/constants.py +++ b/resources/libraries/python/constants.py @@ -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 index 0000000000..0d934977ce --- /dev/null +++ b/resources/tools/papi/vpp_papi_provider.py @@ -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) diff --git a/tests/vpp/func/__init__.robot b/tests/vpp/func/__init__.robot index 9243257c0a..b074d0d5b0 100644 --- a/tests/vpp/func/__init__.robot +++ b/tests/vpp/func/__init__.robot @@ -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} diff --git a/tests/vpp/perf/__init__.robot b/tests/vpp/perf/__init__.robot index bba10eb831..0e24ecda6b 100644 --- a/tests/vpp/perf/__init__.robot +++ b/tests/vpp/perf/__init__.robot @@ -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} -- 2.16.6