Add VPP API CRC checking
[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         self.crc_checker.report_initial_conflicts()
359         if history:
360             PapiHistory.add_to_papi_history(
361                 self._node, csit_papi_command, **kwargs)
362         self.crc_checker.check_api_name(csit_papi_command)
363         self._api_command_list.append(
364             dict(api_name=csit_papi_command, api_args=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                 self.crc_checker.check_api_name(item.__class__.__name__)
523                 dict_item = dictize(item)
524                 if "retval" in dict_item.keys():
525                     # *_details messages do not contain retval.
526                     retval = dict_item["retval"]
527                     if retval != 0:
528                         # TODO: What exactly to log and raise here?
529                         err = AssertionError("Retval {rv!r}".format(rv=retval))
530                         # Lowering log level, some retval!=0 calls are expected.
531                         # TODO: Expose level argument so callers can decide?
532                         raise_from(AssertionError(err_msg), err, level="DEBUG")
533                 replies.append(dict_item)
534         return replies
535
536
537 class PapiExecutor(object):
538     """Contains methods for executing VPP Python API commands on DUTs.
539
540     TODO: Remove .add step, make get_stats accept paths directly.
541
542     This class processes only one type of VPP PAPI methods: vpp-stats.
543
544     The recommended ways of use are (examples):
545
546     path = ['^/if', '/err/ip4-input', '/sys/node/ip4-input']
547     with PapiExecutor(node) as papi_exec:
548         stats = papi_exec.add(api_name='vpp-stats', path=path).get_stats()
549
550     print('RX interface core 0, sw_if_index 0:\n{0}'.\
551         format(stats[0]['/if/rx'][0][0]))
552
553     or
554
555     path_1 = ['^/if', ]
556     path_2 = ['^/if', '/err/ip4-input', '/sys/node/ip4-input']
557     with PapiExecutor(node) as papi_exec:
558         stats = papi_exec.add('vpp-stats', path=path_1).\
559             add('vpp-stats', path=path_2).get_stats()
560
561     print('RX interface core 0, sw_if_index 0:\n{0}'.\
562         format(stats[1]['/if/rx'][0][0]))
563
564     Note: In this case, when PapiExecutor method 'add' is used:
565     - its parameter 'csit_papi_command' is used only to keep information
566       that vpp-stats are requested. It is not further processed but it is
567       included in the PAPI history this way:
568       vpp-stats(path=['^/if', '/err/ip4-input', '/sys/node/ip4-input'])
569       Always use csit_papi_command="vpp-stats" if the VPP PAPI method
570       is "stats".
571     - the second parameter must be 'path' as it is used by PapiExecutor
572       method 'add'.
573     """
574
575     def __init__(self, node):
576         """Initialization.
577
578         :param node: Node to run command(s) on.
579         :type node: dict
580         """
581
582         # Node to run command(s) on.
583         self._node = node
584
585         # The list of PAPI commands to be executed on the node.
586         self._api_command_list = list()
587
588         self._ssh = SSH()
589
590     def __enter__(self):
591         try:
592             self._ssh.connect(self._node)
593         except IOError:
594             raise RuntimeError("Cannot open SSH connection to host {host} to "
595                                "execute PAPI command(s)".
596                                format(host=self._node["host"]))
597         return self
598
599     def __exit__(self, exc_type, exc_val, exc_tb):
600         self._ssh.disconnect(self._node)
601
602     def add(self, csit_papi_command="vpp-stats", history=True, **kwargs):
603         """Add next command to internal command list; return self.
604
605         The argument name 'csit_papi_command' must be unique enough as it cannot
606         be repeated in kwargs.
607
608         :param csit_papi_command: VPP API command.
609         :param history: Enable/disable adding command to PAPI command history.
610         :param kwargs: Optional key-value arguments.
611         :type csit_papi_command: str
612         :type history: bool
613         :type kwargs: dict
614         :returns: self, so that method chaining is possible.
615         :rtype: PapiExecutor
616         """
617         if history:
618             PapiHistory.add_to_papi_history(
619                 self._node, csit_papi_command, **kwargs)
620         self._api_command_list.append(dict(api_name=csit_papi_command,
621                                            api_args=kwargs))
622         return self
623
624     def get_stats(self, err_msg="Failed to get statistics.", timeout=120):
625         """Get VPP Stats from VPP Python API.
626
627         :param err_msg: The message used if the PAPI command(s) execution fails.
628         :param timeout: Timeout in seconds.
629         :type err_msg: str
630         :type timeout: int
631         :returns: Requested VPP statistics.
632         :rtype: list of dict
633         """
634
635         paths = [cmd['api_args']['path'] for cmd in self._api_command_list]
636         self._api_command_list = list()
637
638         stdout = self._execute_papi(
639             paths, method='stats', err_msg=err_msg, timeout=timeout)
640
641         return json.loads(stdout)
642
643     @staticmethod
644     def _process_api_data(api_d):
645         """Process API data for smooth converting to JSON string.
646
647         Apply binascii.hexlify() method for string values.
648
649         :param api_d: List of APIs with their arguments.
650         :type api_d: list
651         :returns: List of APIs with arguments pre-processed for JSON.
652         :rtype: list
653         """
654
655         def process_value(val):
656             """Process value.
657
658             :param val: Value to be processed.
659             :type val: object
660             :returns: Processed value.
661             :rtype: dict or str or int
662             """
663             if isinstance(val, dict):
664                 for val_k, val_v in val.iteritems():
665                     val[str(val_k)] = process_value(val_v)
666                 return val
667             elif isinstance(val, list):
668                 for idx, val_l in enumerate(val):
669                     val[idx] = process_value(val_l)
670                 return val
671             else:
672                 return binascii.hexlify(val) if isinstance(val, str) else val
673
674         api_data_processed = list()
675         for api in api_d:
676             api_args_processed = dict()
677             for a_k, a_v in api["api_args"].iteritems():
678                 api_args_processed[str(a_k)] = process_value(a_v)
679             api_data_processed.append(dict(api_name=api["api_name"],
680                                            api_args=api_args_processed))
681         return api_data_processed
682
683     def _execute_papi(self, api_data, method='request', err_msg="",
684                       timeout=120):
685         """Execute PAPI command(s) on remote node and store the result.
686
687         :param api_data: List of APIs with their arguments.
688         :param method: VPP Python API method. Supported methods are: 'request',
689             'dump' and 'stats'.
690         :param err_msg: The message used if the PAPI command(s) execution fails.
691         :param timeout: Timeout in seconds.
692         :type api_data: list
693         :type method: str
694         :type err_msg: str
695         :type timeout: int
696         :returns: Stdout from remote python utility, to be parsed by caller.
697         :rtype: str
698         :raises SSHTimeout: If PAPI command(s) execution has timed out.
699         :raises RuntimeError: If PAPI executor failed due to another reason.
700         :raises AssertionError: If PAPI command(s) execution has failed.
701         """
702
703         if not api_data:
704             raise RuntimeError("No API data provided.")
705
706         json_data = json.dumps(api_data) \
707             if method in ("stats", "stats_request") \
708             else json.dumps(self._process_api_data(api_data))
709
710         cmd = "{fw_dir}/{papi_provider} --method {method} --data '{json}'".\
711             format(
712                 fw_dir=Constants.REMOTE_FW_DIR, method=method, json=json_data,
713                 papi_provider=Constants.RESOURCES_PAPI_PROVIDER)
714         try:
715             ret_code, stdout, _ = self._ssh.exec_command_sudo(
716                 cmd=cmd, timeout=timeout, log_stdout_err=False)
717         # TODO: Fail on non-empty stderr?
718         except SSHTimeout:
719             logger.error("PAPI command(s) execution timeout on host {host}:"
720                          "\n{apis}".format(host=self._node["host"],
721                                            apis=api_data))
722             raise
723         except Exception as exc:
724             raise_from(RuntimeError(
725                 "PAPI command(s) execution on host {host} "
726                 "failed: {apis}".format(
727                     host=self._node["host"], apis=api_data)), exc)
728         if ret_code != 0:
729             raise AssertionError(err_msg)
730
731         return stdout