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