PAPI: Python 3.8 compatibility
[csit.git] / resources / libraries / python / PapiExecutor.py
1 # Copyright (c) 2021 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 from collections import UserDict
27
28
29 from pprint import pformat
30 from robot.api import logger
31
32 from resources.libraries.python.Constants import Constants
33 from resources.libraries.python.LocalExecution import run
34 from resources.libraries.python.FilteredLogger import FilteredLogger
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__ = [u"PapiExecutor", u"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 named tuple 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, u"_asdict"):
67         return obj
68     overriden = UserDict(obj._asdict())
69     old_get = overriden.__getitem__
70     new_get = lambda self, key: dictize(old_get(self, key))
71     overriden.__getitem__ = new_get
72     return overriden
73
74
75 class PapiSocketExecutor:
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 handling of retval!=0 without try/except in caller.
95
96     Note: Use only with "with" statement, e.g.:
97
98         cmd = 'show_version'
99         with PapiSocketExecutor(node) as papi_exec:
100             reply = papi_exec.add(cmd).get_reply(err_msg)
101
102     This class processes two classes of VPP PAPI methods:
103     1. Simple request / reply: method='request'.
104     2. Dump functions: method='dump'.
105
106     Note that access to VPP stats over socket is not supported yet.
107
108     The recommended ways of use are (examples):
109
110     1. Simple request / reply
111
112     a. One request with no arguments:
113
114         cmd = 'show_version'
115         with PapiSocketExecutor(node) as papi_exec:
116             reply = papi_exec.add(cmd).get_reply(err_msg)
117
118     b. Three requests with arguments, the second and the third ones are the same
119        but with different arguments.
120
121         with PapiSocketExecutor(node) as papi_exec:
122             replies = papi_exec.add(cmd1, **args1).add(cmd2, **args2).\
123                 add(cmd2, **args3).get_replies(err_msg)
124
125     2. Dump functions
126
127         cmd = 'sw_interface_rx_placement_dump'
128         with PapiSocketExecutor(node) as papi_exec:
129             details = papi_exec.add(cmd, sw_if_index=ifc['vpp_sw_index']).\
130                 get_details(err_msg)
131     """
132
133     # Class cache for reuse between instances.
134     vpp_instance = None
135     """Takes long time to create, stores all PAPI functions and types."""
136     crc_checker = None
137     """Accesses .api.json files at creation, caching allows deleting them."""
138
139     def __init__(self, node, remote_vpp_socket=Constants.SOCKSVR_PATH):
140         """Store the given arguments, declare managed variables.
141
142         :param node: Node to connect to and forward unix domain socket from.
143         :param remote_vpp_socket: Path to remote socket to tunnel to.
144         :type node: dict
145         :type remote_vpp_socket: str
146         """
147         self._node = node
148         self._remote_vpp_socket = remote_vpp_socket
149         # The list of PAPI commands to be executed on the node.
150         self._api_command_list = list()
151         # The following values are set on enter, reset on exit.
152         self._temp_dir = None
153         self._ssh_control_socket = None
154         self._local_vpp_socket = None
155         self.initialize_vpp_instance()
156
157     def initialize_vpp_instance(self):
158         """Create VPP instance with bindings to API calls, store as class field.
159
160         No-op if the instance had been stored already.
161
162         The instance is initialized for unix domain socket access,
163         it has initialized all the bindings, but it is not connected
164         (to a local socket) yet.
165
166         This method downloads .api.json files from self._node
167         into a temporary directory, deletes them finally.
168         """
169         if self.vpp_instance:
170             return
171         cls = self.__class__  # Shorthand for setting class fields.
172         package_path = None
173         tmp_dir = tempfile.mkdtemp(dir=u"/tmp")
174         try:
175             # Pack, copy and unpack Python part of VPP installation from _node.
176             # TODO: Use rsync or recursive version of ssh.scp_node instead?
177             node = self._node
178             exec_cmd_no_error(node, [u"rm", u"-rf", u"/tmp/papi.txz"])
179             # Papi python version depends on OS (and time).
180             # Python 2.7 or 3.4, site-packages or dist-packages.
181             installed_papi_glob = u"/usr/lib/python3*/*-packages/vpp_papi"
182             # We need to wrap this command in bash, in order to expand globs,
183             # and as ssh does join, the inner command has to be quoted.
184             inner_cmd = u" ".join([
185                 u"tar", u"cJf", u"/tmp/papi.txz", u"--exclude=*.pyc",
186                 installed_papi_glob, u"/usr/share/vpp/api"
187             ])
188             exec_cmd_no_error(node, [u"bash", u"-c", u"'" + inner_cmd + u"'"])
189             scp_node(node, tmp_dir + u"/papi.txz", u"/tmp/papi.txz", get=True)
190             run([u"tar", u"xf", tmp_dir + u"/papi.txz", u"-C", tmp_dir])
191             api_json_directory = tmp_dir + u"/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(u"/", 1)[0]
199             sys.path.append(package_path)
200             # TODO: Pylint says import-outside-toplevel and import-error.
201             # It is right, we should refactor the code and move initialization
202             # of package outside.
203             from vpp_papi.vpp_papi import VPPApiClient as vpp_class
204             vpp_class.apidir = api_json_directory
205             # We need to create instance before removing from sys.path.
206             cls.vpp_instance = vpp_class(
207                 use_socket=True, server_address=u"TBD", async_thread=False,
208                 read_timeout=14, logger=FilteredLogger(logger, u"INFO"))
209             # Cannot use loglevel parameter, robot.api.logger lacks support.
210             # TODO: Stop overriding read_timeout when VPP-1722 is fixed.
211         finally:
212             shutil.rmtree(tmp_dir)
213             if sys.path[-1] == package_path:
214                 sys.path.pop()
215
216     def __enter__(self):
217         """Create a tunnel, connect VPP instance.
218
219         Only at this point a local socket names are created
220         in a temporary directory, because VIRL runs 3 pybots at once,
221         so harcoding local filenames does not work.
222
223         :returns: self
224         :rtype: PapiSocketExecutor
225         """
226         time_enter = time.time()
227         # Parsing takes longer than connecting, prepare instance before tunnel.
228         vpp_instance = self.vpp_instance
229         node = self._node
230         self._temp_dir = tempfile.mkdtemp(dir=u"/tmp")
231         self._local_vpp_socket = self._temp_dir + u"/vpp-api.sock"
232         self._ssh_control_socket = self._temp_dir + u"/ssh.sock"
233         ssh_socket = self._ssh_control_socket
234         # Cleanup possibilities.
235         ret_code, _ = run([u"ls", ssh_socket], check=False)
236         if ret_code != 2:
237             # This branch never seems to be hit in CI,
238             # but may be useful when testing manually.
239             run(
240                 [u"ssh", u"-S", ssh_socket, u"-O", u"exit", u"0.0.0.0"],
241                 check=False, log=True
242             )
243             # TODO: Is any sleep necessary? How to prove if not?
244             run([u"sleep", u"0.1"])
245             run([u"rm", u"-vrf", ssh_socket])
246         # Even if ssh can perhaps reuse this file,
247         # we need to remove it for readiness detection to work correctly.
248         run([u"rm", u"-rvf", self._local_vpp_socket])
249         # We use sleep command. The ssh command will exit in 30 second,
250         # unless a local socket connection is established,
251         # in which case the ssh command will exit only when
252         # the ssh connection is closed again (via control socket).
253         # The log level is to suppress "Warning: Permanently added" messages.
254         ssh_cmd = [
255             u"ssh", u"-S", ssh_socket, u"-M",
256             u"-o", u"LogLevel=ERROR", u"-o", u"UserKnownHostsFile=/dev/null",
257             u"-o", u"StrictHostKeyChecking=no",
258             u"-o", u"ExitOnForwardFailure=yes",
259             u"-L", self._local_vpp_socket + u":" + self._remote_vpp_socket,
260             u"-p", str(node[u"port"]), node[u"username"] + u"@" + node[u"host"],
261             u"sleep", u"30"
262         ]
263         priv_key = node.get(u"priv_key")
264         if priv_key:
265             # This is tricky. We need a file to pass the value to ssh command.
266             # And we need ssh command, because paramiko does not support sockets
267             # (neither ssh_socket, nor _remote_vpp_socket).
268             key_file = tempfile.NamedTemporaryFile()
269             key_file.write(priv_key)
270             # Make sure the content is written, but do not close yet.
271             key_file.flush()
272             ssh_cmd[1:1] = [u"-i", key_file.name]
273         password = node.get(u"password")
274         if password:
275             # Prepend sshpass command to set password.
276             ssh_cmd[:0] = [u"sshpass", u"-p", password]
277         time_stop = time.time() + 10.0
278         # subprocess.Popen seems to be the best way to run commands
279         # on background. Other ways (shell=True with "&" and ssh with -f)
280         # seem to be too dependent on shell behavior.
281         # In particular, -f does NOT return values for run().
282         subprocess.Popen(ssh_cmd)
283         # Check socket presence on local side.
284         while time.time() < time_stop:
285             # It can take a moment for ssh to create the socket file.
286             ret_code, _ = run(
287                 [u"ls", u"-l", self._local_vpp_socket], check=False
288             )
289             if not ret_code:
290                 break
291             time.sleep(0.1)
292         else:
293             raise RuntimeError(u"Local side socket has not appeared.")
294         if priv_key:
295             # Socket up means the key has been read. Delete file by closing it.
296             key_file.close()
297         # Everything is ready, set the local socket address and connect.
298         vpp_instance.transport.server_address = self._local_vpp_socket
299         # It seems we can get read error even if every preceding check passed.
300         # Single retry seems to help.
301         for _ in range(2):
302             try:
303                 vpp_instance.connect_sync(u"csit_socket")
304             except (IOError, struct.error) as err:
305                 logger.warn(f"Got initial connect error {err!r}")
306                 vpp_instance.disconnect()
307             else:
308                 break
309         else:
310             raise RuntimeError(u"Failed to connect to VPP over a socket.")
311         logger.trace(
312             f"Establishing socket connection took {time.time()-time_enter}s"
313         )
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"{cli_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