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