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