Revert "Disable CRC checking at runtime"
[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             from vpp_papi.vpp_papi import VPPApiClient as vpp_class
197             vpp_class.apidir = api_json_directory
198             # We need to create instance before removing from sys.path.
199             cls.vpp_instance = vpp_class(
200                 use_socket=True, server_address="TBD", async_thread=False,
201                 read_timeout=14, logger=FilteredLogger(logger, "INFO"))
202             # Cannot use loglevel parameter, robot.api.logger lacks support.
203             # TODO: Stop overriding read_timeout when VPP-1722 is fixed.
204         finally:
205             shutil.rmtree(tmp_dir)
206             if sys.path[-1] == package_path:
207                 sys.path.pop()
208
209     def __enter__(self):
210         """Create a tunnel, connect VPP instance.
211
212         Only at this point a local socket names are created
213         in a temporary directory, because VIRL runs 3 pybots at once,
214         so harcoding local filenames does not work.
215
216         :returns: self
217         :rtype: PapiSocketExecutor
218         """
219         # Parsing takes longer than connecting, prepare instance before tunnel.
220         vpp_instance = self.vpp_instance
221         node = self._node
222         self._temp_dir = tempfile.mkdtemp(dir="/tmp")
223         self._local_vpp_socket = self._temp_dir + "/vpp-api.sock"
224         self._ssh_control_socket = self._temp_dir + "/ssh.sock"
225         ssh_socket = self._ssh_control_socket
226         # Cleanup possibilities.
227         ret_code, _ = run(["ls", ssh_socket], check=False)
228         if ret_code != 2:
229             # This branch never seems to be hit in CI,
230             # but may be useful when testing manually.
231             run(["ssh", "-S", ssh_socket, "-O", "exit", "0.0.0.0"],
232                 check=False, log=True)
233             # TODO: Is any sleep necessary? How to prove if not?
234             run(["sleep", "0.1"])
235             run(["rm", "-vrf", ssh_socket])
236         # Even if ssh can perhaps reuse this file,
237         # we need to remove it for readiness detection to work correctly.
238         run(["rm", "-rvf", self._local_vpp_socket])
239         # On VIRL, the ssh user is not added to "vpp" group,
240         # so we need to change remote socket file access rights.
241         exec_cmd_no_error(
242             node, "chmod o+rwx " + self._remote_vpp_socket, sudo=True)
243         # We use sleep command. The ssh command will exit in 10 second,
244         # unless a local socket connection is established,
245         # in which case the ssh command will exit only when
246         # the ssh connection is closed again (via control socket).
247         # The log level is to supress "Warning: Permanently added" messages.
248         ssh_cmd = [
249             "ssh", "-S", ssh_socket, "-M",
250             "-o", "LogLevel=ERROR", "-o", "UserKnownHostsFile=/dev/null",
251             "-o", "StrictHostKeyChecking=no", "-o", "ExitOnForwardFailure=yes",
252             "-L", self._local_vpp_socket + ':' + self._remote_vpp_socket,
253             "-p", str(node['port']), node['username'] + "@" + node['host'],
254             "sleep", "10"]
255         priv_key = node.get("priv_key")
256         if priv_key:
257             # This is tricky. We need a file to pass the value to ssh command.
258             # And we need ssh command, because paramiko does not suport sockets
259             # (neither ssh_socket, nor _remote_vpp_socket).
260             key_file = tempfile.NamedTemporaryFile()
261             key_file.write(priv_key)
262             # Make sure the content is written, but do not close yet.
263             key_file.flush()
264             ssh_cmd[1:1] = ["-i", key_file.name]
265         password = node.get("password")
266         if password:
267             # Prepend sshpass command to set password.
268             ssh_cmd[:0] = ["sshpass", "-p", password]
269         time_stop = time.time() + 10.0
270         # subprocess.Popen seems to be the best way to run commands
271         # on background. Other ways (shell=True with "&" and ssh with -f)
272         # seem to be too dependent on shell behavior.
273         # In particular, -f does NOT return values for run().
274         subprocess.Popen(ssh_cmd)
275         # Check socket presence on local side.
276         while time.time() < time_stop:
277             # It can take a moment for ssh to create the socket file.
278             ret_code, _ = run(["ls", "-l", self._local_vpp_socket], check=False)
279             if not ret_code:
280                 break
281             time.sleep(0.1)
282         else:
283             raise RuntimeError("Local side socket has not appeared.")
284         if priv_key:
285             # Socket up means the key has been read. Delete file by closing it.
286             key_file.close()
287         # Everything is ready, set the local socket address and connect.
288         vpp_instance.transport.server_address = self._local_vpp_socket
289         # It seems we can get read error even if every preceding check passed.
290         # Single retry seems to help.
291         for _ in xrange(2):
292             try:
293                 vpp_instance.connect_sync("csit_socket")
294             except IOError as err:
295                 logger.warn("Got initial connect error {err!r}".format(err=err))
296                 vpp_instance.disconnect()
297             else:
298                 break
299         else:
300             raise RuntimeError("Failed to connect to VPP over a socket.")
301         return self
302
303     def __exit__(self, exc_type, exc_val, exc_tb):
304         """Disconnect the vpp instance, tear down the SHH tunnel.
305
306         Also remove the local sockets by deleting the temporary directory.
307         Arguments related to possible exception are entirely ignored.
308         """
309         self.vpp_instance.disconnect()
310         run(["ssh", "-S", self._ssh_control_socket, "-O", "exit", "0.0.0.0"],
311             check=False)
312         shutil.rmtree(self._temp_dir)
313
314     def add(self, csit_papi_command, history=True, **kwargs):
315         """Add next command to internal command list; return self.
316
317         Unless disabled, new entry to papi history is also added at this point.
318         The argument name 'csit_papi_command' must be unique enough as it cannot
319         be repeated in kwargs.
320         The kwargs dict is deep-copied, so it is safe to use the original
321         with partial modifications for subsequent commands.
322
323         Any pending conflicts from .api.json processing are raised.
324         Then the command name is checked for known CRCs.
325         Unsupported commands raise an exception, as CSIT change
326         should not start using messages without making sure which CRCs
327         are supported.
328         Each CRC issue is raised only once, so subsequent tests
329         can raise other issues.
330
331         :param csit_papi_command: VPP API command.
332         :param history: Enable/disable adding command to PAPI command history.
333         :param kwargs: Optional key-value arguments.
334         :type csit_papi_command: str
335         :type history: bool
336         :type kwargs: dict
337         :returns: self, so that method chaining is possible.
338         :rtype: PapiSocketExecutor
339         :raises RuntimeError: If unverified or conflicting CRC is encountered.
340         """
341         self.crc_checker.report_initial_conflicts()
342         if history:
343             PapiHistory.add_to_papi_history(
344                 self._node, csit_papi_command, **kwargs)
345         self.crc_checker.check_api_name(csit_papi_command)
346         self._api_command_list.append(
347             dict(api_name=csit_papi_command, api_args=copy.deepcopy(kwargs)))
348         return self
349
350     def get_replies(self, err_msg="Failed to get replies."):
351         """Get replies from VPP Python API.
352
353         The replies are parsed into dict-like objects,
354         "retval" field is guaranteed to be zero on success.
355
356         :param err_msg: The message used if the PAPI command(s) execution fails.
357         :type err_msg: str
358         :returns: Responses, dict objects with fields due to API and "retval".
359         :rtype: list of dict
360         :raises RuntimeError: If retval is nonzero, parsing or ssh error.
361         """
362         return self._execute(err_msg=err_msg)
363
364     def get_reply(self, err_msg="Failed to get reply."):
365         """Get reply from VPP Python API.
366
367         The reply is parsed into dict-like object,
368         "retval" field is guaranteed to be zero on success.
369
370         TODO: Discuss exception types to raise, unify with inner methods.
371
372         :param err_msg: The message used if the PAPI command(s) execution fails.
373         :type err_msg: str
374         :returns: Response, dict object with fields due to API and "retval".
375         :rtype: dict
376         :raises AssertionError: If retval is nonzero, parsing or ssh error.
377         """
378         replies = self.get_replies(err_msg=err_msg)
379         if len(replies) != 1:
380             raise RuntimeError("Expected single reply, got {replies!r}".format(
381                 replies=replies))
382         return replies[0]
383
384     def get_sw_if_index(self, err_msg="Failed to get reply."):
385         """Get sw_if_index from reply from VPP Python API.
386
387         Frequently, the caller is only interested in sw_if_index field
388         of the reply, this wrapper makes such call sites shorter.
389
390         TODO: Discuss exception types to raise, unify with inner methods.
391
392         :param err_msg: The message used if the PAPI command(s) execution fails.
393         :type err_msg: str
394         :returns: Response, sw_if_index value of the reply.
395         :rtype: int
396         :raises AssertionError: If retval is nonzero, parsing or ssh error.
397         """
398         reply = self.get_reply(err_msg=err_msg)
399         logger.info("Getting index from {reply!r}".format(reply=reply))
400         return reply["sw_if_index"]
401
402     def get_details(self, err_msg="Failed to get dump details."):
403         """Get dump details from VPP Python API.
404
405         The details are parsed into dict-like objects.
406         The number of details per single dump command can vary,
407         and all association between details and dumps is lost,
408         so if you care about the association (as opposed to
409         logging everything at once for debugging purposes),
410         it is recommended to call get_details for each dump (type) separately.
411
412         :param err_msg: The message used if the PAPI command(s) execution fails.
413         :type err_msg: str
414         :returns: Details, dict objects with fields due to API without "retval".
415         :rtype: list of dict
416         """
417         return self._execute(err_msg)
418
419     @staticmethod
420     def run_cli_cmd(node, cmd, log=True):
421         """Run a CLI command as cli_inband, return the "reply" field of reply.
422
423         Optionally, log the field value.
424
425         :param node: Node to run command on.
426         :param cmd: The CLI command to be run on the node.
427         :param log: If True, the response is logged.
428         :type node: dict
429         :type cmd: str
430         :type log: bool
431         :returns: CLI output.
432         :rtype: str
433         """
434         cli = 'cli_inband'
435         args = dict(cmd=cmd)
436         err_msg = "Failed to run 'cli_inband {cmd}' PAPI command on host " \
437                   "{host}".format(host=node['host'], cmd=cmd)
438         with PapiSocketExecutor(node) as papi_exec:
439             reply = papi_exec.add(cli, **args).get_reply(err_msg)["reply"]
440         if log:
441             logger.info("{cmd}:\n{reply}".format(cmd=cmd, reply=reply))
442         return reply
443
444     @staticmethod
445     def dump_and_log(node, cmds):
446         """Dump and log requested information, return None.
447
448         :param node: DUT node.
449         :param cmds: Dump commands to be executed.
450         :type node: dict
451         :type cmds: list of str
452         """
453         with PapiSocketExecutor(node) as papi_exec:
454             for cmd in cmds:
455                 dump = papi_exec.add(cmd).get_details()
456                 logger.debug("{cmd}:\n{data}".format(
457                     cmd=cmd, data=pformat(dump)))
458
459     def _execute(self, err_msg="Undefined error message"):
460         """Turn internal command list into data and execute; return replies.
461
462         This method also clears the internal command list.
463
464         IMPORTANT!
465         Do not use this method in L1 keywords. Use:
466         - get_replies()
467         - get_reply()
468         - get_sw_if_index()
469         - get_details()
470
471         :param err_msg: The message used if the PAPI command(s) execution fails.
472         :type err_msg: str
473         :returns: Papi responses parsed into a dict-like object,
474             with fields due to API (possibly including retval).
475         :rtype: list of dict
476         :raises RuntimeError: If the replies are not all correct.
477         """
478         vpp_instance = self.vpp_instance
479         local_list = self._api_command_list
480         # Clear first as execution may fail.
481         self._api_command_list = list()
482         replies = list()
483         for command in local_list:
484             api_name = command["api_name"]
485             papi_fn = getattr(vpp_instance.api, api_name)
486             try:
487                 try:
488                     reply = papi_fn(**command["api_args"])
489                 except IOError as err:
490                     # Ocassionally an error happens, try reconnect.
491                     logger.warn("Reconnect after error: {err!r}".format(
492                         err=err))
493                     self.vpp_instance.disconnect()
494                     # Testing showes immediate reconnect fails.
495                     time.sleep(1)
496                     self.vpp_instance.connect_sync("csit_socket")
497                     logger.trace("Reconnected.")
498                     reply = papi_fn(**command["api_args"])
499             except (AttributeError, IOError) as err:
500                 raise_from(AssertionError(err_msg), err, level="INFO")
501             # *_dump commands return list of objects, convert, ordinary reply.
502             if not isinstance(reply, list):
503                 reply = [reply]
504             for item in reply:
505                 self.crc_checker.check_api_name(item.__class__.__name__)
506                 dict_item = dictize(item)
507                 if "retval" in dict_item.keys():
508                     # *_details messages do not contain retval.
509                     retval = dict_item["retval"]
510                     if retval != 0:
511                         # TODO: What exactly to log and raise here?
512                         err = AssertionError("Retval {rv!r}".format(rv=retval))
513                         # Lowering log level, some retval!=0 calls are expected.
514                         # TODO: Expose level argument so callers can decide?
515                         raise_from(AssertionError(err_msg), err, level="DEBUG")
516                 replies.append(dict_item)
517         return replies
518
519
520 class PapiExecutor(object):
521     """Contains methods for executing VPP Python API commands on DUTs.
522
523     TODO: Remove .add step, make get_stats accept paths directly.
524
525     This class processes only one type of VPP PAPI methods: vpp-stats.
526
527     The recommended ways of use are (examples):
528
529     path = ['^/if', '/err/ip4-input', '/sys/node/ip4-input']
530     with PapiExecutor(node) as papi_exec:
531         stats = papi_exec.add(api_name='vpp-stats', path=path).get_stats()
532
533     print('RX interface core 0, sw_if_index 0:\n{0}'.\
534         format(stats[0]['/if/rx'][0][0]))
535
536     or
537
538     path_1 = ['^/if', ]
539     path_2 = ['^/if', '/err/ip4-input', '/sys/node/ip4-input']
540     with PapiExecutor(node) as papi_exec:
541         stats = papi_exec.add('vpp-stats', path=path_1).\
542             add('vpp-stats', path=path_2).get_stats()
543
544     print('RX interface core 0, sw_if_index 0:\n{0}'.\
545         format(stats[1]['/if/rx'][0][0]))
546
547     Note: In this case, when PapiExecutor method 'add' is used:
548     - its parameter 'csit_papi_command' is used only to keep information
549       that vpp-stats are requested. It is not further processed but it is
550       included in the PAPI history this way:
551       vpp-stats(path=['^/if', '/err/ip4-input', '/sys/node/ip4-input'])
552       Always use csit_papi_command="vpp-stats" if the VPP PAPI method
553       is "stats".
554     - the second parameter must be 'path' as it is used by PapiExecutor
555       method 'add'.
556     """
557
558     def __init__(self, node):
559         """Initialization.
560
561         :param node: Node to run command(s) on.
562         :type node: dict
563         """
564
565         # Node to run command(s) on.
566         self._node = node
567
568         # The list of PAPI commands to be executed on the node.
569         self._api_command_list = list()
570
571         self._ssh = SSH()
572
573     def __enter__(self):
574         try:
575             self._ssh.connect(self._node)
576         except IOError:
577             raise RuntimeError("Cannot open SSH connection to host {host} to "
578                                "execute PAPI command(s)".
579                                format(host=self._node["host"]))
580         return self
581
582     def __exit__(self, exc_type, exc_val, exc_tb):
583         self._ssh.disconnect(self._node)
584
585     def add(self, csit_papi_command="vpp-stats", history=True, **kwargs):
586         """Add next command to internal command list; return self.
587
588         The argument name 'csit_papi_command' must be unique enough as it cannot
589         be repeated in kwargs.
590         The kwargs dict is deep-copied, so it is safe to use the original
591         with partial modifications for subsequent commands.
592
593         :param csit_papi_command: VPP API command.
594         :param history: Enable/disable adding command to PAPI command history.
595         :param kwargs: Optional key-value arguments.
596         :type csit_papi_command: str
597         :type history: bool
598         :type kwargs: dict
599         :returns: self, so that method chaining is possible.
600         :rtype: PapiExecutor
601         """
602         if history:
603             PapiHistory.add_to_papi_history(
604                 self._node, csit_papi_command, **kwargs)
605         self._api_command_list.append(dict(
606             api_name=csit_papi_command, api_args=copy.deepcopy(kwargs)))
607         return self
608
609     def get_stats(self, err_msg="Failed to get statistics.", timeout=120):
610         """Get VPP Stats from VPP Python API.
611
612         :param err_msg: The message used if the PAPI command(s) execution fails.
613         :param timeout: Timeout in seconds.
614         :type err_msg: str
615         :type timeout: int
616         :returns: Requested VPP statistics.
617         :rtype: list of dict
618         """
619
620         paths = [cmd['api_args']['path'] for cmd in self._api_command_list]
621         self._api_command_list = list()
622
623         stdout = self._execute_papi(
624             paths, method='stats', err_msg=err_msg, timeout=timeout)
625
626         return json.loads(stdout)
627
628     @staticmethod
629     def _process_api_data(api_d):
630         """Process API data for smooth converting to JSON string.
631
632         Apply binascii.hexlify() method for string values.
633
634         :param api_d: List of APIs with their arguments.
635         :type api_d: list
636         :returns: List of APIs with arguments pre-processed for JSON.
637         :rtype: list
638         """
639
640         def process_value(val):
641             """Process value.
642
643             :param val: Value to be processed.
644             :type val: object
645             :returns: Processed value.
646             :rtype: dict or str or int
647             """
648             if isinstance(val, dict):
649                 for val_k, val_v in val.iteritems():
650                     val[str(val_k)] = process_value(val_v)
651                 return val
652             elif isinstance(val, list):
653                 for idx, val_l in enumerate(val):
654                     val[idx] = process_value(val_l)
655                 return val
656             else:
657                 return binascii.hexlify(val) if isinstance(val, str) else val
658
659         api_data_processed = list()
660         for api in api_d:
661             api_args_processed = dict()
662             for a_k, a_v in api["api_args"].iteritems():
663                 api_args_processed[str(a_k)] = process_value(a_v)
664             api_data_processed.append(dict(api_name=api["api_name"],
665                                            api_args=api_args_processed))
666         return api_data_processed
667
668     def _execute_papi(self, api_data, method='request', err_msg="",
669                       timeout=120):
670         """Execute PAPI command(s) on remote node and store the result.
671
672         :param api_data: List of APIs with their arguments.
673         :param method: VPP Python API method. Supported methods are: 'request',
674             'dump' and 'stats'.
675         :param err_msg: The message used if the PAPI command(s) execution fails.
676         :param timeout: Timeout in seconds.
677         :type api_data: list
678         :type method: str
679         :type err_msg: str
680         :type timeout: int
681         :returns: Stdout from remote python utility, to be parsed by caller.
682         :rtype: str
683         :raises SSHTimeout: If PAPI command(s) execution has timed out.
684         :raises RuntimeError: If PAPI executor failed due to another reason.
685         :raises AssertionError: If PAPI command(s) execution has failed.
686         """
687
688         if not api_data:
689             raise RuntimeError("No API data provided.")
690
691         json_data = json.dumps(api_data) \
692             if method in ("stats", "stats_request") \
693             else json.dumps(self._process_api_data(api_data))
694
695         cmd = "{fw_dir}/{papi_provider} --method {method} --data '{json}'".\
696             format(
697                 fw_dir=Constants.REMOTE_FW_DIR, method=method, json=json_data,
698                 papi_provider=Constants.RESOURCES_PAPI_PROVIDER)
699         try:
700             ret_code, stdout, _ = self._ssh.exec_command_sudo(
701                 cmd=cmd, timeout=timeout, log_stdout_err=False)
702         # TODO: Fail on non-empty stderr?
703         except SSHTimeout:
704             logger.error("PAPI command(s) execution timeout on host {host}:"
705                          "\n{apis}".format(host=self._node["host"],
706                                            apis=api_data))
707             raise
708         except Exception as exc:
709             raise_from(RuntimeError(
710                 "PAPI command(s) execution on host {host} "
711                 "failed: {apis}".format(
712                     host=self._node["host"], apis=api_data)), exc)
713         if ret_code != 0:
714             raise AssertionError(err_msg)
715
716         return stdout