Fix: Deep-copy arguments in papi executors
[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     cached_vpp_instance = None
132     api_json_directory = None
133     crc_checker_instance = None
134
135     def __init__(self, node, remote_vpp_socket="/run/vpp-api.sock"):
136         """Store the given arguments, declare managed variables.
137
138         :param node: Node to connect to and forward unix domain socket from.
139         :param remote_vpp_socket: Path to remote socket to tunnel to.
140         :type node: dict
141         :type remote_vpp_socket: str
142         """
143         self._node = node
144         self._remote_vpp_socket = remote_vpp_socket
145         # The list of PAPI commands to be executed on the node.
146         self._api_command_list = list()
147         # The following values are set on enter, reset on exit.
148         self._temp_dir = None
149         self._ssh_control_socket = None
150         self._local_vpp_socket = None
151
152     def create_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.create_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         Unless disabled, new entry to papi history is also added at this point.
337         The argument name 'csit_papi_command' must be unique enough as it cannot
338         be repeated in kwargs.
339         The kwargs dict is deep-copied, so it is safe to use the original
340         with partial modifications for subsequent commands.
341
342         Any pending conflicts from .api.json processing are raised.
343         Then the command name is checked for known CRCs.
344         Unsupported commands raise an exception, as CSIT change
345         should not start using messages without making sure which CRCs
346         are supported.
347         Each CRC issue is raised only once, so subsequent tests
348         can raise other issues.
349
350         :param csit_papi_command: VPP API command.
351         :param history: Enable/disable adding command to PAPI command history.
352         :param kwargs: Optional key-value arguments.
353         :type csit_papi_command: str
354         :type history: bool
355         :type kwargs: dict
356         :returns: self, so that method chaining is possible.
357         :rtype: PapiSocketExecutor
358         :raises RuntimeError: If unverified or conflicting CRC is encountered.
359         """
360         if history:
361             PapiHistory.add_to_papi_history(
362                 self._node, csit_papi_command, **kwargs)
363         self._api_command_list.append(
364             dict(api_name=csit_papi_command, api_args=copy.deepcopy(kwargs)))
365         return self
366
367     def get_replies(self, err_msg="Failed to get replies."):
368         """Get replies from VPP Python API.
369
370         The replies are parsed into dict-like objects,
371         "retval" field is guaranteed to be zero on success.
372
373         :param err_msg: The message used if the PAPI command(s) execution fails.
374         :type err_msg: str
375         :returns: Responses, dict objects with fields due to API and "retval".
376         :rtype: list of dict
377         :raises RuntimeError: If retval is nonzero, parsing or ssh error.
378         """
379         return self._execute(err_msg=err_msg)
380
381     def get_reply(self, err_msg="Failed to get reply."):
382         """Get reply from VPP Python API.
383
384         The reply is parsed into dict-like object,
385         "retval" field is guaranteed to be zero on success.
386
387         TODO: Discuss exception types to raise, unify with inner methods.
388
389         :param err_msg: The message used if the PAPI command(s) execution fails.
390         :type err_msg: str
391         :returns: Response, dict object with fields due to API and "retval".
392         :rtype: dict
393         :raises AssertionError: If retval is nonzero, parsing or ssh error.
394         """
395         replies = self.get_replies(err_msg=err_msg)
396         if len(replies) != 1:
397             raise RuntimeError("Expected single reply, got {replies!r}".format(
398                 replies=replies))
399         return replies[0]
400
401     def get_sw_if_index(self, err_msg="Failed to get reply."):
402         """Get sw_if_index from reply from VPP Python API.
403
404         Frequently, the caller is only interested in sw_if_index field
405         of the reply, this wrapper makes such call sites shorter.
406
407         TODO: Discuss exception types to raise, unify with inner methods.
408
409         :param err_msg: The message used if the PAPI command(s) execution fails.
410         :type err_msg: str
411         :returns: Response, sw_if_index value of the reply.
412         :rtype: int
413         :raises AssertionError: If retval is nonzero, parsing or ssh error.
414         """
415         reply = self.get_reply(err_msg=err_msg)
416         logger.info("Getting index from {reply!r}".format(reply=reply))
417         return reply["sw_if_index"]
418
419     def get_details(self, err_msg="Failed to get dump details."):
420         """Get dump details from VPP Python API.
421
422         The details are parsed into dict-like objects.
423         The number of details per single dump command can vary,
424         and all association between details and dumps is lost,
425         so if you care about the association (as opposed to
426         logging everything at once for debugging purposes),
427         it is recommended to call get_details for each dump (type) separately.
428
429         :param err_msg: The message used if the PAPI command(s) execution fails.
430         :type err_msg: str
431         :returns: Details, dict objects with fields due to API without "retval".
432         :rtype: list of dict
433         """
434         return self._execute(err_msg)
435
436     @staticmethod
437     def run_cli_cmd(node, cmd, log=True):
438         """Run a CLI command as cli_inband, return the "reply" field of reply.
439
440         Optionally, log the field value.
441
442         :param node: Node to run command on.
443         :param cmd: The CLI command to be run on the node.
444         :param log: If True, the response is logged.
445         :type node: dict
446         :type cmd: str
447         :type log: bool
448         :returns: CLI output.
449         :rtype: str
450         """
451         cli = 'cli_inband'
452         args = dict(cmd=cmd)
453         err_msg = "Failed to run 'cli_inband {cmd}' PAPI command on host " \
454                   "{host}".format(host=node['host'], cmd=cmd)
455         with PapiSocketExecutor(node) as papi_exec:
456             reply = papi_exec.add(cli, **args).get_reply(err_msg)["reply"]
457         if log:
458             logger.info("{cmd}:\n{reply}".format(cmd=cmd, reply=reply))
459         return reply
460
461     @staticmethod
462     def dump_and_log(node, cmds):
463         """Dump and log requested information, return None.
464
465         :param node: DUT node.
466         :param cmds: Dump commands to be executed.
467         :type node: dict
468         :type cmds: list of str
469         """
470         with PapiSocketExecutor(node) as papi_exec:
471             for cmd in cmds:
472                 dump = papi_exec.add(cmd).get_details()
473                 logger.debug("{cmd}:\n{data}".format(
474                     cmd=cmd, data=pformat(dump)))
475
476     def _execute(self, err_msg="Undefined error message"):
477         """Turn internal command list into data and execute; return replies.
478
479         This method also clears the internal command list.
480
481         IMPORTANT!
482         Do not use this method in L1 keywords. Use:
483         - get_replies()
484         - get_reply()
485         - get_sw_if_index()
486         - get_details()
487
488         :param err_msg: The message used if the PAPI command(s) execution fails.
489         :type err_msg: str
490         :returns: Papi responses parsed into a dict-like object,
491             with fields due to API (possibly including retval).
492         :rtype: list of dict
493         :raises RuntimeError: If the replies are not all correct.
494         """
495         vpp_instance = self.vpp_instance
496         local_list = self._api_command_list
497         # Clear first as execution may fail.
498         self._api_command_list = list()
499         replies = list()
500         for command in local_list:
501             api_name = command["api_name"]
502             papi_fn = getattr(vpp_instance.api, api_name)
503             try:
504                 try:
505                     reply = papi_fn(**command["api_args"])
506                 except IOError as err:
507                     # Ocassionally an error happens, try reconnect.
508                     logger.warn("Reconnect after error: {err!r}".format(
509                         err=err))
510                     self.vpp_instance.disconnect()
511                     # Testing showes immediate reconnect fails.
512                     time.sleep(1)
513                     self.vpp_instance.connect_sync("csit_socket")
514                     logger.trace("Reconnected.")
515                     reply = papi_fn(**command["api_args"])
516             except (AttributeError, IOError) as err:
517                 raise_from(AssertionError(err_msg), err, level="INFO")
518             # *_dump commands return list of objects, convert, ordinary reply.
519             if not isinstance(reply, list):
520                 reply = [reply]
521             for item in reply:
522                 dict_item = dictize(item)
523                 if "retval" in dict_item.keys():
524                     # *_details messages do not contain retval.
525                     retval = dict_item["retval"]
526                     if retval != 0:
527                         # TODO: What exactly to log and raise here?
528                         err = AssertionError("Retval {rv!r}".format(rv=retval))
529                         # Lowering log level, some retval!=0 calls are expected.
530                         # TODO: Expose level argument so callers can decide?
531                         raise_from(AssertionError(err_msg), err, level="DEBUG")
532                 replies.append(dict_item)
533         return replies
534
535
536 class PapiExecutor(object):
537     """Contains methods for executing VPP Python API commands on DUTs.
538
539     TODO: Remove .add step, make get_stats accept paths directly.
540
541     This class processes only one type of VPP PAPI methods: vpp-stats.
542
543     The recommended ways of use are (examples):
544
545     path = ['^/if', '/err/ip4-input', '/sys/node/ip4-input']
546     with PapiExecutor(node) as papi_exec:
547         stats = papi_exec.add(api_name='vpp-stats', path=path).get_stats()
548
549     print('RX interface core 0, sw_if_index 0:\n{0}'.\
550         format(stats[0]['/if/rx'][0][0]))
551
552     or
553
554     path_1 = ['^/if', ]
555     path_2 = ['^/if', '/err/ip4-input', '/sys/node/ip4-input']
556     with PapiExecutor(node) as papi_exec:
557         stats = papi_exec.add('vpp-stats', path=path_1).\
558             add('vpp-stats', path=path_2).get_stats()
559
560     print('RX interface core 0, sw_if_index 0:\n{0}'.\
561         format(stats[1]['/if/rx'][0][0]))
562
563     Note: In this case, when PapiExecutor method 'add' is used:
564     - its parameter 'csit_papi_command' is used only to keep information
565       that vpp-stats are requested. It is not further processed but it is
566       included in the PAPI history this way:
567       vpp-stats(path=['^/if', '/err/ip4-input', '/sys/node/ip4-input'])
568       Always use csit_papi_command="vpp-stats" if the VPP PAPI method
569       is "stats".
570     - the second parameter must be 'path' as it is used by PapiExecutor
571       method 'add'.
572     """
573
574     def __init__(self, node):
575         """Initialization.
576
577         :param node: Node to run command(s) on.
578         :type node: dict
579         """
580
581         # Node to run command(s) on.
582         self._node = node
583
584         # The list of PAPI commands to be executed on the node.
585         self._api_command_list = list()
586
587         self._ssh = SSH()
588
589     def __enter__(self):
590         try:
591             self._ssh.connect(self._node)
592         except IOError:
593             raise RuntimeError("Cannot open SSH connection to host {host} to "
594                                "execute PAPI command(s)".
595                                format(host=self._node["host"]))
596         return self
597
598     def __exit__(self, exc_type, exc_val, exc_tb):
599         self._ssh.disconnect(self._node)
600
601     def add(self, csit_papi_command="vpp-stats", history=True, **kwargs):
602         """Add next command to internal command list; return self.
603
604         The argument name 'csit_papi_command' must be unique enough as it cannot
605         be repeated in kwargs.
606         The kwargs dict is deep-copied, so it is safe to use the original
607         with partial modifications for subsequent commands.
608
609         :param csit_papi_command: VPP API command.
610         :param history: Enable/disable adding command to PAPI command history.
611         :param kwargs: Optional key-value arguments.
612         :type csit_papi_command: str
613         :type history: bool
614         :type kwargs: dict
615         :returns: self, so that method chaining is possible.
616         :rtype: PapiExecutor
617         """
618         if history:
619             PapiHistory.add_to_papi_history(
620                 self._node, csit_papi_command, **kwargs)
621         self._api_command_list.append(dict(
622             api_name=csit_papi_command, api_args=copy.deepcopy(kwargs)))
623         return self
624
625     def get_stats(self, err_msg="Failed to get statistics.", timeout=120):
626         """Get VPP Stats from VPP Python API.
627
628         :param err_msg: The message used if the PAPI command(s) execution fails.
629         :param timeout: Timeout in seconds.
630         :type err_msg: str
631         :type timeout: int
632         :returns: Requested VPP statistics.
633         :rtype: list of dict
634         """
635
636         paths = [cmd['api_args']['path'] for cmd in self._api_command_list]
637         self._api_command_list = list()
638
639         stdout = self._execute_papi(
640             paths, method='stats', err_msg=err_msg, timeout=timeout)
641
642         return json.loads(stdout)
643
644     @staticmethod
645     def _process_api_data(api_d):
646         """Process API data for smooth converting to JSON string.
647
648         Apply binascii.hexlify() method for string values.
649
650         :param api_d: List of APIs with their arguments.
651         :type api_d: list
652         :returns: List of APIs with arguments pre-processed for JSON.
653         :rtype: list
654         """
655
656         def process_value(val):
657             """Process value.
658
659             :param val: Value to be processed.
660             :type val: object
661             :returns: Processed value.
662             :rtype: dict or str or int
663             """
664             if isinstance(val, dict):
665                 for val_k, val_v in val.iteritems():
666                     val[str(val_k)] = process_value(val_v)
667                 return val
668             elif isinstance(val, list):
669                 for idx, val_l in enumerate(val):
670                     val[idx] = process_value(val_l)
671                 return val
672             else:
673                 return binascii.hexlify(val) if isinstance(val, str) else val
674
675         api_data_processed = list()
676         for api in api_d:
677             api_args_processed = dict()
678             for a_k, a_v in api["api_args"].iteritems():
679                 api_args_processed[str(a_k)] = process_value(a_v)
680             api_data_processed.append(dict(api_name=api["api_name"],
681                                            api_args=api_args_processed))
682         return api_data_processed
683
684     def _execute_papi(self, api_data, method='request', err_msg="",
685                       timeout=120):
686         """Execute PAPI command(s) on remote node and store the result.
687
688         :param api_data: List of APIs with their arguments.
689         :param method: VPP Python API method. Supported methods are: 'request',
690             'dump' and 'stats'.
691         :param err_msg: The message used if the PAPI command(s) execution fails.
692         :param timeout: Timeout in seconds.
693         :type api_data: list
694         :type method: str
695         :type err_msg: str
696         :type timeout: int
697         :returns: Stdout from remote python utility, to be parsed by caller.
698         :rtype: str
699         :raises SSHTimeout: If PAPI command(s) execution has timed out.
700         :raises RuntimeError: If PAPI executor failed due to another reason.
701         :raises AssertionError: If PAPI command(s) execution has failed.
702         """
703
704         if not api_data:
705             raise RuntimeError("No API data provided.")
706
707         json_data = json.dumps(api_data) \
708             if method in ("stats", "stats_request") \
709             else json.dumps(self._process_api_data(api_data))
710
711         cmd = "{fw_dir}/{papi_provider} --method {method} --data '{json}'".\
712             format(
713                 fw_dir=Constants.REMOTE_FW_DIR, method=method, json=json_data,
714                 papi_provider=Constants.RESOURCES_PAPI_PROVIDER)
715         try:
716             ret_code, stdout, _ = self._ssh.exec_command_sudo(
717                 cmd=cmd, timeout=timeout, log_stdout_err=False)
718         # TODO: Fail on non-empty stderr?
719         except SSHTimeout:
720             logger.error("PAPI command(s) execution timeout on host {host}:"
721                          "\n{apis}".format(host=self._node["host"],
722                                            apis=api_data))
723             raise
724         except Exception as exc:
725             raise_from(RuntimeError(
726                 "PAPI command(s) execution on host {host} "
727                 "failed: {apis}".format(
728                     host=self._node["host"], apis=api_data)), exc)
729         if ret_code != 0:
730             raise AssertionError(err_msg)
731
732         return stdout