FIX: Pylint
[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     def create_crc_checker(self):
152         """Return the cached instance or create new one from directory.
153
154         It is assumed self.api_json_directory is set, as a class variable.
155
156         :returns: Cached or newly created instance aware of .api.json content.
157         :rtype: VppApiCrc.VppApiCrcChecker
158         """
159         cls = self.__class__
160         if cls.crc_checker_instance is None:
161             cls.crc_checker_instance = VppApiCrcChecker(cls.api_json_directory)
162         return cls.crc_checker_instance
163
164     @property
165     def vpp_instance(self):
166         """Return VPP instance with bindings to all API calls.
167
168         The returned instance is initialized for unix domain socket access,
169         it has initialized all the bindings, but it is not connected
170         (to local socket) yet.
171
172         First invocation downloads .api.json files from self._node
173         into a temporary directory.
174
175         After first invocation, the result is cached, so other calls are quick.
176         Class variable is used as the cache, but this property is defined as
177         an instance method, so that _node (for api files) is known.
178
179         :returns: Initialized but not connected VPP instance.
180         :rtype: vpp_papi.VPPApiClient
181         """
182         cls = self.__class__
183         if cls.cached_vpp_instance is not None:
184             return cls.cached_vpp_instance
185         tmp_dir = tempfile.mkdtemp(dir="/tmp")
186         package_path = "Not set yet."
187         try:
188             # Pack, copy and unpack Python part of VPP installation from _node.
189             # TODO: Use rsync or recursive version of ssh.scp_node instead?
190             node = self._node
191             exec_cmd_no_error(node, ["rm", "-rf", "/tmp/papi.txz"])
192             # Papi python version depends on OS (and time).
193             # Python 2.7 or 3.4, site-packages or dist-packages.
194             installed_papi_glob = "/usr/lib/python*/*-packages/vpp_papi"
195             # We need to wrap this command in bash, in order to expand globs,
196             # and as ssh does join, the inner command has to be quoted.
197             inner_cmd = " ".join([
198                 "tar", "cJf", "/tmp/papi.txz", "--exclude=*.pyc",
199                 installed_papi_glob, "/usr/share/vpp/api"])
200             exec_cmd_no_error(node, ["bash", "-c", "'" + inner_cmd + "'"])
201             scp_node(node, tmp_dir + "/papi.txz", "/tmp/papi.txz", get=True)
202             run(["tar", "xf", tmp_dir + "/papi.txz", "-C", tmp_dir])
203             cls.api_json_directory = tmp_dir + "/usr/share/vpp/api"
204             # Perform initial checks before .api.json files are gone,
205             # by accessing the property (which also creates its instance).
206             self.create_crc_checker()
207             # When present locally, we finally can find the installation path.
208             package_path = glob.glob(tmp_dir + installed_papi_glob)[0]
209             # Package path has to be one level above the vpp_papi directory.
210             package_path = package_path.rsplit('/', 1)[0]
211             sys.path.append(package_path)
212             from vpp_papi.vpp_papi import VPPApiClient as vpp_class
213             vpp_class.apidir = cls.api_json_directory
214             # We need to create instance before removing from sys.path.
215             cls.cached_vpp_instance = vpp_class(
216                 use_socket=True, server_address="TBD", async_thread=False,
217                 read_timeout=14, logger=FilteredLogger(logger, "INFO"))
218             # Cannot use loglevel parameter, robot.api.logger lacks support.
219             # TODO: Stop overriding read_timeout when VPP-1722 is fixed.
220         finally:
221             shutil.rmtree(tmp_dir)
222             if sys.path[-1] == package_path:
223                 sys.path.pop()
224         return cls.cached_vpp_instance
225
226     def __enter__(self):
227         """Create a tunnel, connect VPP instance.
228
229         Only at this point a local socket names are created
230         in a temporary directory, because VIRL runs 3 pybots at once,
231         so harcoding local filenames does not work.
232
233         :returns: self
234         :rtype: PapiSocketExecutor
235         """
236         # Parsing takes longer than connecting, prepare instance before tunnel.
237         vpp_instance = self.vpp_instance
238         node = self._node
239         self._temp_dir = tempfile.mkdtemp(dir="/tmp")
240         self._local_vpp_socket = self._temp_dir + "/vpp-api.sock"
241         self._ssh_control_socket = self._temp_dir + "/ssh.sock"
242         ssh_socket = self._ssh_control_socket
243         # Cleanup possibilities.
244         ret_code, _ = run(["ls", ssh_socket], check=False)
245         if ret_code != 2:
246             # This branch never seems to be hit in CI,
247             # but may be useful when testing manually.
248             run(["ssh", "-S", ssh_socket, "-O", "exit", "0.0.0.0"],
249                 check=False, log=True)
250             # TODO: Is any sleep necessary? How to prove if not?
251             run(["sleep", "0.1"])
252             run(["rm", "-vrf", ssh_socket])
253         # Even if ssh can perhaps reuse this file,
254         # we need to remove it for readiness detection to work correctly.
255         run(["rm", "-rvf", self._local_vpp_socket])
256         # On VIRL, the ssh user is not added to "vpp" group,
257         # so we need to change remote socket file access rights.
258         exec_cmd_no_error(
259             node, "chmod o+rwx " + self._remote_vpp_socket, sudo=True)
260         # We use sleep command. The ssh command will exit in 10 second,
261         # unless a local socket connection is established,
262         # in which case the ssh command will exit only when
263         # the ssh connection is closed again (via control socket).
264         # The log level is to supress "Warning: Permanently added" messages.
265         ssh_cmd = [
266             "ssh", "-S", ssh_socket, "-M",
267             "-o", "LogLevel=ERROR", "-o", "UserKnownHostsFile=/dev/null",
268             "-o", "StrictHostKeyChecking=no", "-o", "ExitOnForwardFailure=yes",
269             "-L", self._local_vpp_socket + ':' + self._remote_vpp_socket,
270             "-p", str(node['port']), node['username'] + "@" + node['host'],
271             "sleep", "10"]
272         priv_key = node.get("priv_key")
273         if priv_key:
274             # This is tricky. We need a file to pass the value to ssh command.
275             # And we need ssh command, because paramiko does not suport sockets
276             # (neither ssh_socket, nor _remote_vpp_socket).
277             key_file = tempfile.NamedTemporaryFile()
278             key_file.write(priv_key)
279             # Make sure the content is written, but do not close yet.
280             key_file.flush()
281             ssh_cmd[1:1] = ["-i", key_file.name]
282         password = node.get("password")
283         if password:
284             # Prepend sshpass command to set password.
285             ssh_cmd[:0] = ["sshpass", "-p", password]
286         time_stop = time.time() + 10.0
287         # subprocess.Popen seems to be the best way to run commands
288         # on background. Other ways (shell=True with "&" and ssh with -f)
289         # seem to be too dependent on shell behavior.
290         # In particular, -f does NOT return values for run().
291         subprocess.Popen(ssh_cmd)
292         # Check socket presence on local side.
293         while time.time() < time_stop:
294             # It can take a moment for ssh to create the socket file.
295             ret_code, _ = run(["ls", "-l", self._local_vpp_socket], check=False)
296             if not ret_code:
297                 break
298             time.sleep(0.1)
299         else:
300             raise RuntimeError("Local side socket has not appeared.")
301         if priv_key:
302             # Socket up means the key has been read. Delete file by closing it.
303             key_file.close()
304         # Everything is ready, set the local socket address and connect.
305         vpp_instance.transport.server_address = self._local_vpp_socket
306         # It seems we can get read error even if every preceding check passed.
307         # Single retry seems to help.
308         for _ in xrange(2):
309             try:
310                 vpp_instance.connect_sync("csit_socket")
311             except IOError as err:
312                 logger.warn("Got initial connect error {err!r}".format(err=err))
313                 vpp_instance.disconnect()
314             else:
315                 break
316         else:
317             raise RuntimeError("Failed to connect to VPP over a socket.")
318         return self
319
320     def __exit__(self, exc_type, exc_val, exc_tb):
321         """Disconnect the vpp instance, tear down the SHH tunnel.
322
323         Also remove the local sockets by deleting the temporary directory.
324         Arguments related to possible exception are entirely ignored.
325         """
326         self.vpp_instance.disconnect()
327         run(["ssh", "-S", self._ssh_control_socket, "-O", "exit", "0.0.0.0"],
328             check=False)
329         shutil.rmtree(self._temp_dir)
330         return
331
332     def add(self, csit_papi_command, history=True, **kwargs):
333         """Add next command to internal command list; return self.
334
335         The argument name 'csit_papi_command' must be unique enough as it cannot
336         be repeated in kwargs.
337         Unless disabled, new entry to papi history is also added at this point.
338
339         Any pending conflicts from .api.json processing are raised.
340         Then the command name is checked for known CRCs.
341         Unsupported commands raise an exception, as CSIT change
342         should not start using messages without making sure which CRCs
343         are supported.
344         Each CRC issue is raised only once, so subsequent tests
345         can raise other issues.
346
347         :param csit_papi_command: VPP API command.
348         :param history: Enable/disable adding command to PAPI command history.
349         :param kwargs: Optional key-value arguments.
350         :type csit_papi_command: str
351         :type history: bool
352         :type kwargs: dict
353         :returns: self, so that method chaining is possible.
354         :rtype: PapiSocketExecutor
355         :raises RuntimeError: If unverified or conflicting CRC is encountered.
356         """
357         if history:
358             PapiHistory.add_to_papi_history(
359                 self._node, csit_papi_command, **kwargs)
360         self._api_command_list.append(
361             dict(api_name=csit_papi_command, api_args=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                 dict_item = dictize(item)
520                 if "retval" in dict_item.keys():
521                     # *_details messages do not contain retval.
522                     retval = dict_item["retval"]
523                     if retval != 0:
524                         # TODO: What exactly to log and raise here?
525                         err = AssertionError("Retval {rv!r}".format(rv=retval))
526                         # Lowering log level, some retval!=0 calls are expected.
527                         # TODO: Expose level argument so callers can decide?
528                         raise_from(AssertionError(err_msg), err, level="DEBUG")
529                 replies.append(dict_item)
530         return replies
531
532
533 class PapiExecutor(object):
534     """Contains methods for executing VPP Python API commands on DUTs.
535
536     TODO: Remove .add step, make get_stats accept paths directly.
537
538     This class processes only one type of VPP PAPI methods: vpp-stats.
539
540     The recommended ways of use are (examples):
541
542     path = ['^/if', '/err/ip4-input', '/sys/node/ip4-input']
543     with PapiExecutor(node) as papi_exec:
544         stats = papi_exec.add(api_name='vpp-stats', path=path).get_stats()
545
546     print('RX interface core 0, sw_if_index 0:\n{0}'.\
547         format(stats[0]['/if/rx'][0][0]))
548
549     or
550
551     path_1 = ['^/if', ]
552     path_2 = ['^/if', '/err/ip4-input', '/sys/node/ip4-input']
553     with PapiExecutor(node) as papi_exec:
554         stats = papi_exec.add('vpp-stats', path=path_1).\
555             add('vpp-stats', path=path_2).get_stats()
556
557     print('RX interface core 0, sw_if_index 0:\n{0}'.\
558         format(stats[1]['/if/rx'][0][0]))
559
560     Note: In this case, when PapiExecutor method 'add' is used:
561     - its parameter 'csit_papi_command' is used only to keep information
562       that vpp-stats are requested. It is not further processed but it is
563       included in the PAPI history this way:
564       vpp-stats(path=['^/if', '/err/ip4-input', '/sys/node/ip4-input'])
565       Always use csit_papi_command="vpp-stats" if the VPP PAPI method
566       is "stats".
567     - the second parameter must be 'path' as it is used by PapiExecutor
568       method 'add'.
569     """
570
571     def __init__(self, node):
572         """Initialization.
573
574         :param node: Node to run command(s) on.
575         :type node: dict
576         """
577
578         # Node to run command(s) on.
579         self._node = node
580
581         # The list of PAPI commands to be executed on the node.
582         self._api_command_list = list()
583
584         self._ssh = SSH()
585
586     def __enter__(self):
587         try:
588             self._ssh.connect(self._node)
589         except IOError:
590             raise RuntimeError("Cannot open SSH connection to host {host} to "
591                                "execute PAPI command(s)".
592                                format(host=self._node["host"]))
593         return self
594
595     def __exit__(self, exc_type, exc_val, exc_tb):
596         self._ssh.disconnect(self._node)
597
598     def add(self, csit_papi_command="vpp-stats", history=True, **kwargs):
599         """Add next command to internal command list; return self.
600
601         The argument name 'csit_papi_command' must be unique enough as it cannot
602         be repeated in kwargs.
603
604         :param csit_papi_command: VPP API command.
605         :param history: Enable/disable adding command to PAPI command history.
606         :param kwargs: Optional key-value arguments.
607         :type csit_papi_command: str
608         :type history: bool
609         :type kwargs: dict
610         :returns: self, so that method chaining is possible.
611         :rtype: PapiExecutor
612         """
613         if history:
614             PapiHistory.add_to_papi_history(
615                 self._node, csit_papi_command, **kwargs)
616         self._api_command_list.append(dict(api_name=csit_papi_command,
617                                            api_args=kwargs))
618         return self
619
620     def get_stats(self, err_msg="Failed to get statistics.", timeout=120):
621         """Get VPP Stats from VPP Python API.
622
623         :param err_msg: The message used if the PAPI command(s) execution fails.
624         :param timeout: Timeout in seconds.
625         :type err_msg: str
626         :type timeout: int
627         :returns: Requested VPP statistics.
628         :rtype: list of dict
629         """
630
631         paths = [cmd['api_args']['path'] for cmd in self._api_command_list]
632         self._api_command_list = list()
633
634         stdout = self._execute_papi(
635             paths, method='stats', err_msg=err_msg, timeout=timeout)
636
637         return json.loads(stdout)
638
639     @staticmethod
640     def _process_api_data(api_d):
641         """Process API data for smooth converting to JSON string.
642
643         Apply binascii.hexlify() method for string values.
644
645         :param api_d: List of APIs with their arguments.
646         :type api_d: list
647         :returns: List of APIs with arguments pre-processed for JSON.
648         :rtype: list
649         """
650
651         def process_value(val):
652             """Process value.
653
654             :param val: Value to be processed.
655             :type val: object
656             :returns: Processed value.
657             :rtype: dict or str or int
658             """
659             if isinstance(val, dict):
660                 for val_k, val_v in val.iteritems():
661                     val[str(val_k)] = process_value(val_v)
662                 return val
663             elif isinstance(val, list):
664                 for idx, val_l in enumerate(val):
665                     val[idx] = process_value(val_l)
666                 return val
667             else:
668                 return binascii.hexlify(val) if isinstance(val, str) else val
669
670         api_data_processed = list()
671         for api in api_d:
672             api_args_processed = dict()
673             for a_k, a_v in api["api_args"].iteritems():
674                 api_args_processed[str(a_k)] = process_value(a_v)
675             api_data_processed.append(dict(api_name=api["api_name"],
676                                            api_args=api_args_processed))
677         return api_data_processed
678
679     def _execute_papi(self, api_data, method='request', err_msg="",
680                       timeout=120):
681         """Execute PAPI command(s) on remote node and store the result.
682
683         :param api_data: List of APIs with their arguments.
684         :param method: VPP Python API method. Supported methods are: 'request',
685             'dump' and 'stats'.
686         :param err_msg: The message used if the PAPI command(s) execution fails.
687         :param timeout: Timeout in seconds.
688         :type api_data: list
689         :type method: str
690         :type err_msg: str
691         :type timeout: int
692         :returns: Stdout from remote python utility, to be parsed by caller.
693         :rtype: str
694         :raises SSHTimeout: If PAPI command(s) execution has timed out.
695         :raises RuntimeError: If PAPI executor failed due to another reason.
696         :raises AssertionError: If PAPI command(s) execution has failed.
697         """
698
699         if not api_data:
700             raise RuntimeError("No API data provided.")
701
702         json_data = json.dumps(api_data) \
703             if method in ("stats", "stats_request") \
704             else json.dumps(self._process_api_data(api_data))
705
706         cmd = "{fw_dir}/{papi_provider} --method {method} --data '{json}'".\
707             format(
708                 fw_dir=Constants.REMOTE_FW_DIR, method=method, json=json_data,
709                 papi_provider=Constants.RESOURCES_PAPI_PROVIDER)
710         try:
711             ret_code, stdout, _ = self._ssh.exec_command_sudo(
712                 cmd=cmd, timeout=timeout, log_stdout_err=False)
713         # TODO: Fail on non-empty stderr?
714         except SSHTimeout:
715             logger.error("PAPI command(s) execution timeout on host {host}:"
716                          "\n{apis}".format(host=self._node["host"],
717                                            apis=api_data))
718             raise
719         except Exception as exc:
720             raise_from(RuntimeError(
721                 "PAPI command(s) execution on host {host} "
722                 "failed: {apis}".format(
723                     host=self._node["host"], apis=api_data)), exc)
724         if ret_code != 0:
725             raise AssertionError(err_msg)
726
727         return stdout