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