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