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