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