FIX: Disable API checker during runtime
[csit.git] / resources / libraries / python / PapiExecutor.py
1 # Copyright (c) 2019 Cisco and/or its affiliates.
2 # Licensed under the Apache License, Version 2.0 (the "License");
3 # you may not use this file except in compliance with the License.
4 # You may obtain a copy of the License at:
5 #
6 #     http://www.apache.org/licenses/LICENSE-2.0
7 #
8 # Unless required by applicable law or agreed to in writing, software
9 # distributed under the License is distributed on an "AS IS" BASIS,
10 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11 # See the License for the specific language governing permissions and
12 # limitations under the License.
13
14 """Python API executor library.
15 """
16
17 import binascii
18 import glob
19 import json
20 import shutil
21 import subprocess
22 import sys
23 import tempfile
24 import time
25
26 from pprint import pformat
27 from robot.api import logger
28
29 from resources.libraries.python.Constants import Constants
30 from resources.libraries.python.LocalExecution import run
31 from resources.libraries.python.FilteredLogger import FilteredLogger
32 from resources.libraries.python.PythonThree import raise_from
33 from resources.libraries.python.PapiHistory import PapiHistory
34 from resources.libraries.python.ssh import (
35     SSH, SSHTimeout, exec_cmd_no_error, scp_node)
36 from resources.libraries.python.VppApiCrc import VppApiCrcChecker
37
38
39 __all__ = ["PapiExecutor", "PapiSocketExecutor"]
40
41
42 def dictize(obj):
43     """A helper method, to make namedtuple-like object accessible as dict.
44
45     If the object is namedtuple-like, its _asdict() form is returned,
46     but in the returned object __getitem__ method is wrapped
47     to dictize also any items returned.
48     If the object does not have _asdict, it will be returned without any change.
49     Integer keys still access the object as tuple.
50
51     A more useful version would be to keep obj mostly as a namedtuple,
52     just add getitem for string keys. Unfortunately, namedtuple inherits
53     from tuple, including its read-only __getitem__ attribute,
54     so we cannot monkey-patch it.
55
56     TODO: Create a proxy for namedtuple to allow that.
57
58     :param obj: Arbitrary object to dictize.
59     :type obj: object
60     :returns: Dictized object.
61     :rtype: same as obj type or collections.OrderedDict
62     """
63     if not hasattr(obj, "_asdict"):
64         return obj
65     ret = obj._asdict()
66     old_get = ret.__getitem__
67     new_get = lambda self, key: dictize(old_get(self, key))
68     ret.__getitem__ = new_get
69     return ret
70
71
72 class PapiSocketExecutor(object):
73     """Methods for executing VPP Python API commands on forwarded socket.
74
75     The current implementation connects for the duration of resource manager.
76     Delay for accepting connection is 10s, and disconnect is explicit.
77     TODO: Decrease 10s to value that is long enough for creating connection
78     and short enough to not affect performance.
79
80     The current implementation downloads and parses .api.json files only once
81     and stores a VPPApiClient instance (disconnected) as a class variable.
82     Accessing multiple nodes with different APIs is therefore not supported.
83
84     The current implementation seems to run into read error occasionally.
85     Not sure if the error is in Python code on Robot side, ssh forwarding,
86     or socket handling at VPP side. Anyway, reconnect after some sleep
87     seems to help, hoping repeated command execution does not lead to surprises.
88     The reconnection is logged at WARN level, so it is prominently shown
89     in log.html, so we can see how frequently it happens.
90
91     TODO: Support sockets in NFs somehow.
92     TODO: Support handling of retval!=0 without try/except in caller.
93
94     Note: Use only with "with" statement, e.g.:
95
96         with PapiSocketExecutor(node) as papi_exec:
97             reply = papi_exec.add('show_version').get_reply(err_msg)
98
99     This class processes two classes of VPP PAPI methods:
100     1. Simple request / reply: method='request'.
101     2. Dump functions: method='dump'.
102
103     Note that access to VPP stats over socket is not supported yet.
104
105     The recommended ways of use are (examples):
106
107     1. Simple request / reply
108
109     a. One request with no arguments:
110
111         with PapiSocketExecutor(node) as papi_exec:
112             reply = papi_exec.add('show_version').get_reply(err_msg)
113
114     b. Three requests with arguments, the second and the third ones are the same
115        but with different arguments.
116
117         with PapiSocketExecutor(node) as papi_exec:
118             replies = papi_exec.add(cmd1, **args1).add(cmd2, **args2).\
119                 add(cmd2, **args3).get_replies(err_msg)
120
121     2. Dump functions
122
123         cmd = 'sw_interface_rx_placement_dump'
124         with PapiSocketExecutor(node) as papi_exec:
125             details = papi_exec.add(cmd, sw_if_index=ifc['vpp_sw_index']).\
126                 get_details(err_msg)
127     """
128
129     # Class cache for reuse between instances.
130     cached_vpp_instance = None
131     api_json_directory = None
132     crc_checker_instance = None
133
134     def __init__(self, node, remote_vpp_socket="/run/vpp-api.sock"):
135         """Store the given arguments, declare managed variables.
136
137         :param node: Node to connect to and forward unix domain socket from.
138         :param remote_vpp_socket: Path to remote socket to tunnel to.
139         :type node: dict
140         :type remote_vpp_socket: str
141         """
142         self._node = node
143         self._remote_vpp_socket = remote_vpp_socket
144         # The list of PAPI commands to be executed on the node.
145         self._api_command_list = list()
146         # The following values are set on enter, reset on exit.
147         self._temp_dir = None
148         self._ssh_control_socket = None
149         self._local_vpp_socket = None
150
151     @property
152     def crc_checker(self):
153         """Return the cached instance or create new one from directory.
154
155         It is assumed self.api_json_directory is set, as a class variable.
156
157         :returns: Cached or newly created instance aware of .api.json content.
158         :rtype: VppApiCrc.VppApiCrcChecker
159         """
160         cls = self.__class__
161         if cls.crc_checker_instance is None:
162             cls.crc_checker_instance = VppApiCrcChecker(cls.api_json_directory)
163         return cls.crc_checker_instance
164
165     @property
166     def vpp_instance(self):
167         """Return VPP instance with bindings to all API calls.
168
169         The returned instance is initialized for unix domain socket access,
170         it has initialized all the bindings, but it is not connected
171         (to local socket) yet.
172
173         First invocation downloads .api.json files from self._node
174         into a temporary directory.
175
176         After first invocation, the result is cached, so other calls are quick.
177         Class variable is used as the cache, but this property is defined as
178         an instance method, so that _node (for api files) is known.
179
180         :returns: Initialized but not connected VPP instance.
181         :rtype: vpp_papi.VPPApiClient
182         """
183         cls = self.__class__
184         if cls.cached_vpp_instance is not None:
185             return cls.cached_vpp_instance
186         tmp_dir = tempfile.mkdtemp(dir="/tmp")
187         package_path = "Not set yet."
188         try:
189             # Pack, copy and unpack Python part of VPP installation from _node.
190             # TODO: Use rsync or recursive version of ssh.scp_node instead?
191             node = self._node
192             exec_cmd_no_error(node, ["rm", "-rf", "/tmp/papi.txz"])
193             # Papi python version depends on OS (and time).
194             # Python 2.7 or 3.4, site-packages or dist-packages.
195             installed_papi_glob = "/usr/lib/python*/*-packages/vpp_papi"
196             # We need to wrap this command in bash, in order to expand globs,
197             # and as ssh does join, the inner command has to be quoted.
198             inner_cmd = " ".join([
199                 "tar", "cJf", "/tmp/papi.txz", "--exclude=*.pyc",
200                 installed_papi_glob, "/usr/share/vpp/api"])
201             exec_cmd_no_error(node, ["bash", "-c", "'" + inner_cmd + "'"])
202             scp_node(node, tmp_dir + "/papi.txz", "/tmp/papi.txz", get=True)
203             run(["tar", "xf", tmp_dir + "/papi.txz", "-C", tmp_dir])
204             cls.api_json_directory = tmp_dir + "/usr/share/vpp/api"
205             # Perform initial checks before .api.json files are gone,
206             # by accessing the property (which also creates its instance).
207             self.crc_checker
208             # When present locally, we finally can find the installation path.
209             package_path = glob.glob(tmp_dir + installed_papi_glob)[0]
210             # Package path has to be one level above the vpp_papi directory.
211             package_path = package_path.rsplit('/', 1)[0]
212             sys.path.append(package_path)
213             from vpp_papi.vpp_papi import VPPApiClient as vpp_class
214             vpp_class.apidir = cls.api_json_directory
215             # We need to create instance before removing from sys.path.
216             cls.cached_vpp_instance = vpp_class(
217                 use_socket=True, server_address="TBD", async_thread=False,
218                 read_timeout=14, logger=FilteredLogger(logger, "INFO"))
219             # Cannot use loglevel parameter, robot.api.logger lacks support.
220             # TODO: Stop overriding read_timeout when VPP-1722 is fixed.
221         finally:
222             shutil.rmtree(tmp_dir)
223             if sys.path[-1] == package_path:
224                 sys.path.pop()
225         return cls.cached_vpp_instance
226
227     def __enter__(self):
228         """Create a tunnel, connect VPP instance.
229
230         Only at this point a local socket names are created
231         in a temporary directory, because VIRL runs 3 pybots at once,
232         so harcoding local filenames does not work.
233
234         :returns: self
235         :rtype: PapiSocketExecutor
236         """
237         # Parsing takes longer than connecting, prepare instance before tunnel.
238         vpp_instance = self.vpp_instance
239         node = self._node
240         self._temp_dir = tempfile.mkdtemp(dir="/tmp")
241         self._local_vpp_socket = self._temp_dir + "/vpp-api.sock"
242         self._ssh_control_socket = self._temp_dir + "/ssh.sock"
243         ssh_socket = self._ssh_control_socket
244         # Cleanup possibilities.
245         ret_code, _ = run(["ls", ssh_socket], check=False)
246         if ret_code != 2:
247             # This branch never seems to be hit in CI,
248             # but may be useful when testing manually.
249             run(["ssh", "-S", ssh_socket, "-O", "exit", "0.0.0.0"],
250                 check=False, log=True)
251             # TODO: Is any sleep necessary? How to prove if not?
252             run(["sleep", "0.1"])
253             run(["rm", "-vrf", ssh_socket])
254         # Even if ssh can perhaps reuse this file,
255         # we need to remove it for readiness detection to work correctly.
256         run(["rm", "-rvf", self._local_vpp_socket])
257         # On VIRL, the ssh user is not added to "vpp" group,
258         # so we need to change remote socket file access rights.
259         exec_cmd_no_error(
260             node, "chmod o+rwx " + self._remote_vpp_socket, sudo=True)
261         # We use sleep command. The ssh command will exit in 10 second,
262         # unless a local socket connection is established,
263         # in which case the ssh command will exit only when
264         # the ssh connection is closed again (via control socket).
265         # The log level is to supress "Warning: Permanently added" messages.
266         ssh_cmd = [
267             "ssh", "-S", ssh_socket, "-M",
268             "-o", "LogLevel=ERROR", "-o", "UserKnownHostsFile=/dev/null",
269             "-o", "StrictHostKeyChecking=no", "-o", "ExitOnForwardFailure=yes",
270             "-L", self._local_vpp_socket + ':' + self._remote_vpp_socket,
271             "-p", str(node['port']), node['username'] + "@" + node['host'],
272             "sleep", "10"]
273         priv_key = node.get("priv_key")
274         if priv_key:
275             # This is tricky. We need a file to pass the value to ssh command.
276             # And we need ssh command, because paramiko does not suport sockets
277             # (neither ssh_socket, nor _remote_vpp_socket).
278             key_file = tempfile.NamedTemporaryFile()
279             key_file.write(priv_key)
280             # Make sure the content is written, but do not close yet.
281             key_file.flush()
282             ssh_cmd[1:1] = ["-i", key_file.name]
283         password = node.get("password")
284         if password:
285             # Prepend sshpass command to set password.
286             ssh_cmd[:0] = ["sshpass", "-p", password]
287         time_stop = time.time() + 10.0
288         # subprocess.Popen seems to be the best way to run commands
289         # on background. Other ways (shell=True with "&" and ssh with -f)
290         # seem to be too dependent on shell behavior.
291         # In particular, -f does NOT return values for run().
292         subprocess.Popen(ssh_cmd)
293         # Check socket presence on local side.
294         while time.time() < time_stop:
295             # It can take a moment for ssh to create the socket file.
296             ret_code, _ = run(["ls", "-l", self._local_vpp_socket], check=False)
297             if not ret_code:
298                 break
299             time.sleep(0.1)
300         else:
301             raise RuntimeError("Local side socket has not appeared.")
302         if priv_key:
303             # Socket up means the key has been read. Delete file by closing it.
304             key_file.close()
305         # Everything is ready, set the local socket address and connect.
306         vpp_instance.transport.server_address = self._local_vpp_socket
307         # It seems we can get read error even if every preceding check passed.
308         # Single retry seems to help.
309         for _ in xrange(2):
310             try:
311                 vpp_instance.connect_sync("csit_socket")
312             except IOError as err:
313                 logger.warn("Got initial connect error {err!r}".format(err=err))
314                 vpp_instance.disconnect()
315             else:
316                 break
317         else:
318             raise RuntimeError("Failed to connect to VPP over a socket.")
319         return self
320
321     def __exit__(self, exc_type, exc_val, exc_tb):
322         """Disconnect the vpp instance, tear down the SHH tunnel.
323
324         Also remove the local sockets by deleting the temporary directory.
325         Arguments related to possible exception are entirely ignored.
326         """
327         self.vpp_instance.disconnect()
328         run(["ssh", "-S", self._ssh_control_socket, "-O", "exit", "0.0.0.0"],
329             check=False)
330         shutil.rmtree(self._temp_dir)
331         return
332
333     def add(self, csit_papi_command, history=True, **kwargs):
334         """Add next command to internal command list; return self.
335
336         The argument name 'csit_papi_command' must be unique enough as it cannot
337         be repeated in kwargs.
338         Unless disabled, new entry to papi history is also added at this point.
339
340         Any pending conflicts from .api.json processing are raised.
341         Then the command name is checked for known CRCs.
342         Unsupported commands raise an exception, as CSIT change
343         should not start using messages without making sure which CRCs
344         are supported.
345         Each CRC issue is raised only once, so subsequent tests
346         can raise other issues.
347
348         :param csit_papi_command: VPP API command.
349         :param history: Enable/disable adding command to PAPI command history.
350         :param kwargs: Optional key-value arguments.
351         :type csit_papi_command: str
352         :type history: bool
353         :type kwargs: dict
354         :returns: self, so that method chaining is possible.
355         :rtype: PapiSocketExecutor
356         :raises RuntimeError: If unverified or conflicting CRC is encountered.
357         """
358         if history:
359             PapiHistory.add_to_papi_history(
360                 self._node, csit_papi_command, **kwargs)
361         self._api_command_list.append(
362             dict(api_name=csit_papi_command, api_args=kwargs))
363         return self
364
365     def get_replies(self, err_msg="Failed to get replies."):
366         """Get replies from VPP Python API.
367
368         The replies are parsed into dict-like objects,
369         "retval" field is guaranteed to be zero on success.
370
371         :param err_msg: The message used if the PAPI command(s) execution fails.
372         :type err_msg: str
373         :returns: Responses, dict objects with fields due to API and "retval".
374         :rtype: list of dict
375         :raises RuntimeError: If retval is nonzero, parsing or ssh error.
376         """
377         return self._execute(err_msg=err_msg)
378
379     def get_reply(self, err_msg="Failed to get reply."):
380         """Get reply from VPP Python API.
381
382         The reply is parsed into dict-like object,
383         "retval" field is guaranteed to be zero on success.
384
385         TODO: Discuss exception types to raise, unify with inner methods.
386
387         :param err_msg: The message used if the PAPI command(s) execution fails.
388         :type err_msg: str
389         :returns: Response, dict object with fields due to API and "retval".
390         :rtype: dict
391         :raises AssertionError: If retval is nonzero, parsing or ssh error.
392         """
393         replies = self.get_replies(err_msg=err_msg)
394         if len(replies) != 1:
395             raise RuntimeError("Expected single reply, got {replies!r}".format(
396                 replies=replies))
397         return replies[0]
398
399     def get_sw_if_index(self, err_msg="Failed to get reply."):
400         """Get sw_if_index from reply from VPP Python API.
401
402         Frequently, the caller is only interested in sw_if_index field
403         of the reply, this wrapper makes such call sites shorter.
404
405         TODO: Discuss exception types to raise, unify with inner methods.
406
407         :param err_msg: The message used if the PAPI command(s) execution fails.
408         :type err_msg: str
409         :returns: Response, sw_if_index value of the reply.
410         :rtype: int
411         :raises AssertionError: If retval is nonzero, parsing or ssh error.
412         """
413         reply = self.get_reply(err_msg=err_msg)
414         logger.info("Getting index from {reply!r}".format(reply=reply))
415         return reply["sw_if_index"]
416
417     def get_details(self, err_msg="Failed to get dump details."):
418         """Get dump details from VPP Python API.
419
420         The details are parsed into dict-like objects.
421         The number of details per single dump command can vary,
422         and all association between details and dumps is lost,
423         so if you care about the association (as opposed to
424         logging everything at once for debugging purposes),
425         it is recommended to call get_details for each dump (type) separately.
426
427         :param err_msg: The message used if the PAPI command(s) execution fails.
428         :type err_msg: str
429         :returns: Details, dict objects with fields due to API without "retval".
430         :rtype: list of dict
431         """
432         return self._execute(err_msg)
433
434     @staticmethod
435     def run_cli_cmd(node, cmd, log=True):
436         """Run a CLI command as cli_inband, return the "reply" field of reply.
437
438         Optionally, log the field value.
439
440         :param node: Node to run command on.
441         :param cmd: The CLI command to be run on the node.
442         :param log: If True, the response is logged.
443         :type node: dict
444         :type cmd: str
445         :type log: bool
446         :returns: CLI output.
447         :rtype: str
448         """
449         cli = 'cli_inband'
450         args = dict(cmd=cmd)
451         err_msg = "Failed to run 'cli_inband {cmd}' PAPI command on host " \
452                   "{host}".format(host=node['host'], cmd=cmd)
453         with PapiSocketExecutor(node) as papi_exec:
454             reply = papi_exec.add(cli, **args).get_reply(err_msg)["reply"]
455         if log:
456             logger.info("{cmd}:\n{reply}".format(cmd=cmd, reply=reply))
457         return reply
458
459     @staticmethod
460     def dump_and_log(node, cmds):
461         """Dump and log requested information, return None.
462
463         :param node: DUT node.
464         :param cmds: Dump commands to be executed.
465         :type node: dict
466         :type cmds: list of str
467         """
468         with PapiSocketExecutor(node) as papi_exec:
469             for cmd in cmds:
470                 dump = papi_exec.add(cmd).get_details()
471                 logger.debug("{cmd}:\n{data}".format(
472                     cmd=cmd, data=pformat(dump)))
473
474     def _execute(self, err_msg="Undefined error message"):
475         """Turn internal command list into data and execute; return replies.
476
477         This method also clears the internal command list.
478
479         IMPORTANT!
480         Do not use this method in L1 keywords. Use:
481         - get_replies()
482         - get_reply()
483         - get_sw_if_index()
484         - get_details()
485
486         :param err_msg: The message used if the PAPI command(s) execution fails.
487         :type err_msg: str
488         :returns: Papi responses parsed into a dict-like object,
489             with fields due to API (possibly including retval).
490         :rtype: list of dict
491         :raises RuntimeError: If the replies are not all correct.
492         """
493         vpp_instance = self.vpp_instance
494         local_list = self._api_command_list
495         # Clear first as execution may fail.
496         self._api_command_list = list()
497         replies = list()
498         for command in local_list:
499             api_name = command["api_name"]
500             papi_fn = getattr(vpp_instance.api, api_name)
501             try:
502                 try:
503                     reply = papi_fn(**command["api_args"])
504                 except IOError as err:
505                     # Ocassionally an error happens, try reconnect.
506                     logger.warn("Reconnect after error: {err!r}".format(
507                         err=err))
508                     self.vpp_instance.disconnect()
509                     # Testing showes immediate reconnect fails.
510                     time.sleep(1)
511                     self.vpp_instance.connect_sync("csit_socket")
512                     logger.trace("Reconnected.")
513                     reply = papi_fn(**command["api_args"])
514             except (AttributeError, IOError) as err:
515                 raise_from(AssertionError(err_msg), err, level="INFO")
516             # *_dump commands return list of objects, convert, ordinary reply.
517             if not isinstance(reply, list):
518                 reply = [reply]
519             for item in reply:
520                 dict_item = dictize(item)
521                 if "retval" in dict_item.keys():
522                     # *_details messages do not contain retval.
523                     retval = dict_item["retval"]
524                     if retval != 0:
525                         # TODO: What exactly to log and raise here?
526                         err = AssertionError("Retval {rv!r}".format(rv=retval))
527                         # Lowering log level, some retval!=0 calls are expected.
528                         # TODO: Expose level argument so callers can decide?
529                         raise_from(AssertionError(err_msg), err, level="DEBUG")
530                 replies.append(dict_item)
531         return replies
532
533
534 class PapiExecutor(object):
535     """Contains methods for executing VPP Python API commands on DUTs.
536
537     TODO: Remove .add step, make get_stats accept paths directly.
538
539     This class processes only one type of VPP PAPI methods: vpp-stats.
540
541     The recommended ways of use are (examples):
542
543     path = ['^/if', '/err/ip4-input', '/sys/node/ip4-input']
544     with PapiExecutor(node) as papi_exec:
545         stats = papi_exec.add(api_name='vpp-stats', path=path).get_stats()
546
547     print('RX interface core 0, sw_if_index 0:\n{0}'.\
548         format(stats[0]['/if/rx'][0][0]))
549
550     or
551
552     path_1 = ['^/if', ]
553     path_2 = ['^/if', '/err/ip4-input', '/sys/node/ip4-input']
554     with PapiExecutor(node) as papi_exec:
555         stats = papi_exec.add('vpp-stats', path=path_1).\
556             add('vpp-stats', path=path_2).get_stats()
557
558     print('RX interface core 0, sw_if_index 0:\n{0}'.\
559         format(stats[1]['/if/rx'][0][0]))
560
561     Note: In this case, when PapiExecutor method 'add' is used:
562     - its parameter 'csit_papi_command' is used only to keep information
563       that vpp-stats are requested. It is not further processed but it is
564       included in the PAPI history this way:
565       vpp-stats(path=['^/if', '/err/ip4-input', '/sys/node/ip4-input'])
566       Always use csit_papi_command="vpp-stats" if the VPP PAPI method
567       is "stats".
568     - the second parameter must be 'path' as it is used by PapiExecutor
569       method 'add'.
570     """
571
572     def __init__(self, node):
573         """Initialization.
574
575         :param node: Node to run command(s) on.
576         :type node: dict
577         """
578
579         # Node to run command(s) on.
580         self._node = node
581
582         # The list of PAPI commands to be executed on the node.
583         self._api_command_list = list()
584
585         self._ssh = SSH()
586
587     def __enter__(self):
588         try:
589             self._ssh.connect(self._node)
590         except IOError:
591             raise RuntimeError("Cannot open SSH connection to host {host} to "
592                                "execute PAPI command(s)".
593                                format(host=self._node["host"]))
594         return self
595
596     def __exit__(self, exc_type, exc_val, exc_tb):
597         self._ssh.disconnect(self._node)
598
599     def add(self, csit_papi_command="vpp-stats", history=True, **kwargs):
600         """Add next command to internal command list; return self.
601
602         The argument name 'csit_papi_command' must be unique enough as it cannot
603         be repeated in kwargs.
604
605         :param csit_papi_command: VPP API command.
606         :param history: Enable/disable adding command to PAPI command history.
607         :param kwargs: Optional key-value arguments.
608         :type csit_papi_command: str
609         :type history: bool
610         :type kwargs: dict
611         :returns: self, so that method chaining is possible.
612         :rtype: PapiExecutor
613         """
614         if history:
615             PapiHistory.add_to_papi_history(
616                 self._node, csit_papi_command, **kwargs)
617         self._api_command_list.append(dict(api_name=csit_papi_command,
618                                            api_args=kwargs))
619         return self
620
621     def get_stats(self, err_msg="Failed to get statistics.", timeout=120):
622         """Get VPP Stats from VPP Python API.
623
624         :param err_msg: The message used if the PAPI command(s) execution fails.
625         :param timeout: Timeout in seconds.
626         :type err_msg: str
627         :type timeout: int
628         :returns: Requested VPP statistics.
629         :rtype: list of dict
630         """
631
632         paths = [cmd['api_args']['path'] for cmd in self._api_command_list]
633         self._api_command_list = list()
634
635         stdout = self._execute_papi(
636             paths, method='stats', err_msg=err_msg, timeout=timeout)
637
638         return json.loads(stdout)
639
640     @staticmethod
641     def _process_api_data(api_d):
642         """Process API data for smooth converting to JSON string.
643
644         Apply binascii.hexlify() method for string values.
645
646         :param api_d: List of APIs with their arguments.
647         :type api_d: list
648         :returns: List of APIs with arguments pre-processed for JSON.
649         :rtype: list
650         """
651
652         def process_value(val):
653             """Process value.
654
655             :param val: Value to be processed.
656             :type val: object
657             :returns: Processed value.
658             :rtype: dict or str or int
659             """
660             if isinstance(val, dict):
661                 for val_k, val_v in val.iteritems():
662                     val[str(val_k)] = process_value(val_v)
663                 return val
664             elif isinstance(val, list):
665                 for idx, val_l in enumerate(val):
666                     val[idx] = process_value(val_l)
667                 return val
668             else:
669                 return binascii.hexlify(val) if isinstance(val, str) else val
670
671         api_data_processed = list()
672         for api in api_d:
673             api_args_processed = dict()
674             for a_k, a_v in api["api_args"].iteritems():
675                 api_args_processed[str(a_k)] = process_value(a_v)
676             api_data_processed.append(dict(api_name=api["api_name"],
677                                            api_args=api_args_processed))
678         return api_data_processed
679
680     def _execute_papi(self, api_data, method='request', err_msg="",
681                       timeout=120):
682         """Execute PAPI command(s) on remote node and store the result.
683
684         :param api_data: List of APIs with their arguments.
685         :param method: VPP Python API method. Supported methods are: 'request',
686             'dump' and 'stats'.
687         :param err_msg: The message used if the PAPI command(s) execution fails.
688         :param timeout: Timeout in seconds.
689         :type api_data: list
690         :type method: str
691         :type err_msg: str
692         :type timeout: int
693         :returns: Stdout from remote python utility, to be parsed by caller.
694         :rtype: str
695         :raises SSHTimeout: If PAPI command(s) execution has timed out.
696         :raises RuntimeError: If PAPI executor failed due to another reason.
697         :raises AssertionError: If PAPI command(s) execution has failed.
698         """
699
700         if not api_data:
701             raise RuntimeError("No API data provided.")
702
703         json_data = json.dumps(api_data) \
704             if method in ("stats", "stats_request") \
705             else json.dumps(self._process_api_data(api_data))
706
707         cmd = "{fw_dir}/{papi_provider} --method {method} --data '{json}'".\
708             format(
709                 fw_dir=Constants.REMOTE_FW_DIR, method=method, json=json_data,
710                 papi_provider=Constants.RESOURCES_PAPI_PROVIDER)
711         try:
712             ret_code, stdout, _ = self._ssh.exec_command_sudo(
713                 cmd=cmd, timeout=timeout, log_stdout_err=False)
714         # TODO: Fail on non-empty stderr?
715         except SSHTimeout:
716             logger.error("PAPI command(s) execution timeout on host {host}:"
717                          "\n{apis}".format(host=self._node["host"],
718                                            apis=api_data))
719             raise
720         except Exception as exc:
721             raise_from(RuntimeError(
722                 "PAPI command(s) execution on host {host} "
723                 "failed: {apis}".format(
724                     host=self._node["host"], apis=api_data)), exc)
725         if ret_code != 0:
726             raise AssertionError(err_msg)
727
728         return stdout