3n-tsh timeout, testbed tags and ansible update
[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=14, 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                 try:
472                     reply = papi_fn(**command["api_args"])
473                 except IOError as err:
474                     # Ocassionally an error happens, try reconnect.
475                     logger.warn("Reconnect after error: {err!r}".format(
476                         err=err))
477                     self.vpp_instance.disconnect()
478                     # Testing showes immediate reconnect fails.
479                     time.sleep(1)
480                     self.vpp_instance.connect_sync("csit_socket")
481                     logger.trace("Reconnected.")
482                     reply = papi_fn(**command["api_args"])
483             except (AttributeError, IOError) as err:
484                 raise_from(AssertionError(err_msg), err, level="INFO")
485             # *_dump commands return list of objects, convert, ordinary reply.
486             if not isinstance(reply, list):
487                 reply = [reply]
488             for item in reply:
489                 dict_item = dictize(item)
490                 if "retval" in dict_item.keys():
491                     # *_details messages do not contain retval.
492                     retval = dict_item["retval"]
493                     if retval != 0:
494                         # TODO: What exactly to log and raise here?
495                         err = AssertionError("Retval {rv!r}".format(rv=retval))
496                         # Lowering log level, some retval!=0 calls are expected.
497                         # TODO: Expose level argument so callers can decide?
498                         raise_from(AssertionError(err_msg), err, level="DEBUG")
499                 replies.append(dict_item)
500         return replies
501
502
503 class PapiExecutor(object):
504     """Contains methods for executing VPP Python API commands on DUTs.
505
506     TODO: Remove .add step, make get_stats accept paths directly.
507
508     This class processes only one type of VPP PAPI methods: vpp-stats.
509
510     The recommended ways of use are (examples):
511
512     path = ['^/if', '/err/ip4-input', '/sys/node/ip4-input']
513     with PapiExecutor(node) as papi_exec:
514         stats = papi_exec.add(api_name='vpp-stats', path=path).get_stats()
515
516     print('RX interface core 0, sw_if_index 0:\n{0}'.\
517         format(stats[0]['/if/rx'][0][0]))
518
519     or
520
521     path_1 = ['^/if', ]
522     path_2 = ['^/if', '/err/ip4-input', '/sys/node/ip4-input']
523     with PapiExecutor(node) as papi_exec:
524         stats = papi_exec.add('vpp-stats', path=path_1).\
525             add('vpp-stats', path=path_2).get_stats()
526
527     print('RX interface core 0, sw_if_index 0:\n{0}'.\
528         format(stats[1]['/if/rx'][0][0]))
529
530     Note: In this case, when PapiExecutor method 'add' is used:
531     - its parameter 'csit_papi_command' is used only to keep information
532       that vpp-stats are requested. It is not further processed but it is
533       included in the PAPI history this way:
534       vpp-stats(path=['^/if', '/err/ip4-input', '/sys/node/ip4-input'])
535       Always use csit_papi_command="vpp-stats" if the VPP PAPI method
536       is "stats".
537     - the second parameter must be 'path' as it is used by PapiExecutor
538       method 'add'.
539     """
540
541     def __init__(self, node):
542         """Initialization.
543
544         :param node: Node to run command(s) on.
545         :type node: dict
546         """
547
548         # Node to run command(s) on.
549         self._node = node
550
551         # The list of PAPI commands to be executed on the node.
552         self._api_command_list = list()
553
554         self._ssh = SSH()
555
556     def __enter__(self):
557         try:
558             self._ssh.connect(self._node)
559         except IOError:
560             raise RuntimeError("Cannot open SSH connection to host {host} to "
561                                "execute PAPI command(s)".
562                                format(host=self._node["host"]))
563         return self
564
565     def __exit__(self, exc_type, exc_val, exc_tb):
566         self._ssh.disconnect(self._node)
567
568     def add(self, csit_papi_command="vpp-stats", history=True, **kwargs):
569         """Add next command to internal command list; return self.
570
571         The argument name 'csit_papi_command' must be unique enough as it cannot
572         be repeated in kwargs.
573
574         :param csit_papi_command: VPP API command.
575         :param history: Enable/disable adding command to PAPI command history.
576         :param kwargs: Optional key-value arguments.
577         :type csit_papi_command: str
578         :type history: bool
579         :type kwargs: dict
580         :returns: self, so that method chaining is possible.
581         :rtype: PapiExecutor
582         """
583         if history:
584             PapiHistory.add_to_papi_history(
585                 self._node, csit_papi_command, **kwargs)
586         self._api_command_list.append(dict(api_name=csit_papi_command,
587                                            api_args=kwargs))
588         return self
589
590     def get_stats(self, err_msg="Failed to get statistics.", timeout=120):
591         """Get VPP Stats from VPP Python API.
592
593         :param err_msg: The message used if the PAPI command(s) execution fails.
594         :param timeout: Timeout in seconds.
595         :type err_msg: str
596         :type timeout: int
597         :returns: Requested VPP statistics.
598         :rtype: list of dict
599         """
600
601         paths = [cmd['api_args']['path'] for cmd in self._api_command_list]
602         self._api_command_list = list()
603
604         stdout = self._execute_papi(
605             paths, method='stats', err_msg=err_msg, timeout=timeout)
606
607         return json.loads(stdout)
608
609     @staticmethod
610     def _process_api_data(api_d):
611         """Process API data for smooth converting to JSON string.
612
613         Apply binascii.hexlify() method for string values.
614
615         :param api_d: List of APIs with their arguments.
616         :type api_d: list
617         :returns: List of APIs with arguments pre-processed for JSON.
618         :rtype: list
619         """
620
621         def process_value(val):
622             """Process value.
623
624             :param val: Value to be processed.
625             :type val: object
626             :returns: Processed value.
627             :rtype: dict or str or int
628             """
629             if isinstance(val, dict):
630                 for val_k, val_v in val.iteritems():
631                     val[str(val_k)] = process_value(val_v)
632                 return val
633             elif isinstance(val, list):
634                 for idx, val_l in enumerate(val):
635                     val[idx] = process_value(val_l)
636                 return val
637             else:
638                 return binascii.hexlify(val) if isinstance(val, str) else val
639
640         api_data_processed = list()
641         for api in api_d:
642             api_args_processed = dict()
643             for a_k, a_v in api["api_args"].iteritems():
644                 api_args_processed[str(a_k)] = process_value(a_v)
645             api_data_processed.append(dict(api_name=api["api_name"],
646                                            api_args=api_args_processed))
647         return api_data_processed
648
649     def _execute_papi(self, api_data, method='request', err_msg="",
650                       timeout=120):
651         """Execute PAPI command(s) on remote node and store the result.
652
653         :param api_data: List of APIs with their arguments.
654         :param method: VPP Python API method. Supported methods are: 'request',
655             'dump' and 'stats'.
656         :param err_msg: The message used if the PAPI command(s) execution fails.
657         :param timeout: Timeout in seconds.
658         :type api_data: list
659         :type method: str
660         :type err_msg: str
661         :type timeout: int
662         :returns: Stdout from remote python utility, to be parsed by caller.
663         :rtype: str
664         :raises SSHTimeout: If PAPI command(s) execution has timed out.
665         :raises RuntimeError: If PAPI executor failed due to another reason.
666         :raises AssertionError: If PAPI command(s) execution has failed.
667         """
668
669         if not api_data:
670             raise RuntimeError("No API data provided.")
671
672         json_data = json.dumps(api_data) \
673             if method in ("stats", "stats_request") \
674             else json.dumps(self._process_api_data(api_data))
675
676         cmd = "{fw_dir}/{papi_provider} --method {method} --data '{json}'".\
677             format(
678                 fw_dir=Constants.REMOTE_FW_DIR, method=method, json=json_data,
679                 papi_provider=Constants.RESOURCES_PAPI_PROVIDER)
680         try:
681             ret_code, stdout, _ = self._ssh.exec_command_sudo(
682                 cmd=cmd, timeout=timeout, log_stdout_err=False)
683         # TODO: Fail on non-empty stderr?
684         except SSHTimeout:
685             logger.error("PAPI command(s) execution timeout on host {host}:"
686                          "\n{apis}".format(host=self._node["host"],
687                                            apis=api_data))
688             raise
689         except Exception as exc:
690             raise_from(RuntimeError(
691                 "PAPI command(s) execution on host {host} "
692                 "failed: {apis}".format(
693                     host=self._node["host"], apis=api_data)), exc)
694         if ret_code != 0:
695             raise AssertionError(err_msg)
696
697         return stdout