feat(swasync): switch to polling mode
[csit.git] / resources / libraries / python / PapiExecutor.py
1 # Copyright (c) 2023 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 TODO: Document sync and async handling properly.
17 """
18
19 import copy
20 import glob
21 import json
22 import logging
23 import shutil
24 import struct  # vpp-papi can raise struct.error
25 import subprocess
26 import sys
27 import tempfile
28 import time
29 from collections import deque, UserDict
30
31 from pprint import pformat
32 from robot.api import logger
33
34 from resources.libraries.python.Constants import Constants
35 from resources.libraries.python.LocalExecution import run
36 from resources.libraries.python.FilteredLogger import FilteredLogger
37 from resources.libraries.python.PapiHistory import PapiHistory
38 from resources.libraries.python.ssh import (
39     SSH,
40     SSHTimeout,
41     exec_cmd_no_error,
42     scp_node,
43 )
44 from resources.libraries.python.topology import Topology, SocketType
45 from resources.libraries.python.VppApiCrc import VppApiCrcChecker
46
47
48 __all__ = [
49     "PapiExecutor",
50     "PapiSocketExecutor",
51     "Disconnector",
52 ]
53
54
55 def dictize(obj):
56     """A helper method, to make namedtuple-like object accessible as dict.
57
58     If the object is namedtuple-like, its _asdict() form is returned,
59     but in the returned object __getitem__ method is wrapped
60     to dictize also any items returned.
61     If the object does not have _asdict, it will be returned without any change.
62     Integer keys still access the object as tuple.
63
64     A more useful version would be to keep obj mostly as a namedtuple,
65     just add getitem for string keys. Unfortunately, namedtuple inherits
66     from tuple, including its read-only __getitem__ attribute,
67     so we cannot monkey-patch it.
68
69     TODO: Create a proxy for named tuple to allow that.
70
71     :param obj: Arbitrary object to dictize.
72     :type obj: object
73     :returns: Dictized object.
74     :rtype: same as obj type or collections.UserDict
75     """
76     if not hasattr(obj, "_asdict"):
77         return obj
78     overriden = UserDict(obj._asdict())
79     old_get = overriden.__getitem__
80     overriden.__getitem__ = lambda self, key: dictize(old_get(self, key))
81     return overriden
82
83
84 def dictize_and_check_retval(obj, err_msg):
85     """Make namedtuple-like object accessible as dict, check retval if exists.
86
87     If the object contains "retval" field, raise when the value is non-zero.
88
89     See dictize() for what it means to dictize.
90
91     :param obj: Arbitrary object to dictize.
92     :param err_msg: The (additional) text for the raised exception.
93     :type obj: object
94     :type err_msg: str
95     :returns: Dictized object.
96     :rtype: same as obj type or collections.UserDict
97     :raises AssertionError: If retval field is present with nonzero value.
98     """
99     ret = dictize(obj)
100     # *_details messages do not contain retval.
101     retval = ret.get("retval", 0)
102     if retval != 0:
103         raise AssertionError(f"{err_msg}\nRetval nonzero in object {ret!r}")
104     return ret
105
106
107 class PapiSocketExecutor:
108     """Methods for executing VPP Python API commands on forwarded socket.
109
110     The current implementation downloads and parses .api.json files only once
111     and caches client instances for reuse.
112     Cleanup metadata is added as additional attributes
113     directly to the client instances.
114
115     The current implementation caches the connected client instances.
116     As a downside, clients need to be explicitly told to disconnect
117     before VPP restart.
118
119     The current implementation seems to run into read error occasionally.
120     Not sure if the error is in Python code on Robot side, ssh forwarding,
121     or socket handling at VPP side. Anyway, reconnect after some sleep
122     seems to help, hoping repeated command execution does not lead to surprises.
123     The reconnection is logged at WARN level, so it is prominently shown
124     in log.html, so we can see how frequently it happens.
125     There are similar retries cleanups in other places
126     (so unresponsive VPPs do not break test much more than needed),
127     but it is hard to verify all that works correctly.
128     Especially, if Robot crashes, files and ssh processes may leak.
129
130     TODO: Decrease current timeout value when creating connections
131     so broken VPP does not prolong job duration too much
132     while good VPP (almost) never fails to connect.
133
134     TODO: Support handling of retval!=0 without try/except in caller.
135
136     This class processes two classes of VPP PAPI methods:
137     1. Simple request / reply: method='request'.
138     2. Dump functions: method='dump'.
139
140     Note that access to VPP stats over socket is not supported yet.
141
142     The recommended ways of use are (examples):
143
144     1. Simple request / reply. Example with no arguments:
145
146         cmd = "show_version"
147         with PapiSocketExecutor(node) as papi_exec:
148             reply = papi_exec.add(cmd).get_reply(err_msg)
149
150     2. Dump functions:
151
152         cmd = "sw_interface_rx_placement_dump"
153         with PapiSocketExecutor(node) as papi_exec:
154             papi_exec.add(cmd, sw_if_index=ifc["vpp_sw_index"])
155             details = papi_exec.get_details(err_msg)
156
157     3. Multiple requests with one reply each.
158        In this example, there are three requests with arguments,
159        the second and the third ones are the same but with different arguments.
160        This example also showcases method chaining.
161
162         with PapiSocketExecutor(node, is_async=True) as papi_exec:
163             replies = papi_exec.add(cmd1, **args1).add(cmd2, **args2).\
164                 add(cmd2, **args3).get_replies(err_msg)
165
166     The "is_async=True" part in the last example enables "async handling mode",
167     which imposes limitations but gains speed and saves memory.
168     This is different than async mode of VPP PAPI, as the default handling mode
169     also uses async PAPI connections.
170
171     The implementation contains more hidden details, such as
172     support for old VPP PAPI async mode behavior, API CRC checking
173     conditional usage of control ping, and possible susceptibility to VPP-2033.
174     See docstring of methods for more detailed info.
175     """
176
177     # Class cache for reuse between instances.
178     api_root_dir = None
179     """We copy .api json files and PAPI code from DUT to robot machine.
180     This class variable holds temporary directory once created.
181     When python exits, the directory is deleted, so no downloaded file leaks.
182     The value will be set to TemporaryDirectory class instance (not string path)
183     to ensure deletion at exit."""
184     api_json_path = None
185     """String path to .api.json files, a directory somewhere in api_root_dir."""
186     api_package_path = None
187     """String path to PAPI code, a different directory under api_root_dir."""
188     crc_checker = None
189     """Accesses .api.json files at creation, caching speeds up accessing it."""
190     reusable_vpp_client_list = list()
191     """Each connection needs a separate client instance,
192     and each client instance creation needs to parse all .api files,
193     which takes time. If a client instance disconnects, it is put here,
194     so on next connect we can reuse intead of creating new."""
195     conn_cache = dict()
196     """Mapping from node key to connected client instance."""
197
198     def __init__(
199         self, node, remote_vpp_socket=Constants.SOCKSVR_PATH, is_async=False
200     ):
201         """Store the given arguments, declare managed variables.
202
203         :param node: Node to connect to and forward unix domain socket from.
204         :param remote_vpp_socket: Path to remote socket to tunnel to.
205         :param is_async: Whether to use async handling.
206         :type node: dict
207         :type remote_vpp_socket: str
208         :type is_async: bool
209         """
210         self._node = node
211         self._remote_vpp_socket = remote_vpp_socket
212         self._is_async = is_async
213         # The list of PAPI commands to be executed on the node.
214         self._api_command_list = list()
215
216     def ensure_api_dirs(self):
217         """Copy files from DUT to local temporary directory.
218
219         If the directory is still there, do not copy again.
220         If copying, also initialize CRC checker (this also performs
221         static checks), and remember PAPI package path.
222         Do not add that to PATH yet.
223         """
224         cls = self.__class__
225         if cls.api_package_path:
226             return
227         # Pylint suggests to use "with" statement, which we cannot,
228         # do as the dir should stay for multiple ensure_vpp_instance calls.
229         cls.api_root_dir = tempfile.TemporaryDirectory(dir="/tmp")
230         root_path = cls.api_root_dir.name
231         # Pack, copy and unpack Python part of VPP installation from _node.
232         # TODO: Use rsync or recursive version of ssh.scp_node instead?
233         node = self._node
234         exec_cmd_no_error(node, ["rm", "-rf", "/tmp/papi.txz"])
235         # Papi python version depends on OS (and time).
236         # Python 3.4 or higher, site-packages or dist-packages.
237         installed_papi_glob = "/usr/lib/python3*/*-packages/vpp_papi"
238         # We need to wrap this command in bash, in order to expand globs,
239         # and as ssh does join, the inner command has to be quoted.
240         inner_cmd = " ".join(
241             [
242                 "tar",
243                 "cJf",
244                 "/tmp/papi.txz",
245                 "--exclude=*.pyc",
246                 installed_papi_glob,
247                 "/usr/share/vpp/api",
248             ]
249         )
250         exec_cmd_no_error(node, ["bash", "-c", f"'{inner_cmd}'"])
251         scp_node(node, root_path + "/papi.txz", "/tmp/papi.txz", get=True)
252         run(["tar", "xf", root_path + "/papi.txz", "-C", root_path])
253         cls.api_json_path = root_path + "/usr/share/vpp/api"
254         # Perform initial checks before .api.json files are gone,
255         # by creating the checker instance.
256         cls.crc_checker = VppApiCrcChecker(cls.api_json_path)
257         # When present locally, we finally can find the installation path.
258         cls.api_package_path = glob.glob(root_path + installed_papi_glob)[0]
259         # Package path has to be one level above the vpp_papi directory.
260         cls.api_package_path = cls.api_package_path.rsplit("/", 1)[0]
261
262     def ensure_vpp_instance(self):
263         """Create or reuse a closed client instance, return it.
264
265         The instance is initialized for unix domain socket access,
266         it has initialized all the bindings, it is removed from the internal
267         list of disconnected instances, but it is not connected
268         (to a local socket) yet.
269
270         :returns: VPP client instance ready for connect.
271         :rtype: vpp_papi.VPPApiClient
272         """
273         self.ensure_api_dirs()
274         cls = self.__class__
275         if cls.reusable_vpp_client_list:
276             # Reuse in LIFO fashion.
277             *cls.reusable_vpp_client_list, ret = cls.reusable_vpp_client_list
278             return ret
279         # Creating an instance leads to dynamic imports from VPP PAPI code,
280         # so the package directory has to be present until the instance.
281         # But it is simpler to keep the package dir around.
282         try:
283             sys.path.append(cls.api_package_path)
284             # TODO: Pylint says import-outside-toplevel and import-error.
285             # It is right, we should refactor the code and move initialization
286             # of package outside.
287             from vpp_papi.vpp_papi import VPPApiClient as vpp_class
288
289             vpp_class.apidir = cls.api_json_path
290             # We need to create instance before removing from sys.path.
291             # Cannot use loglevel parameter, robot.api.logger lacks the support.
292             vpp_instance = vpp_class(
293                 use_socket=True,
294                 server_address="TBD",
295                 async_thread=False,
296                 # Large read timeout was originally there for VPP-1722,
297                 # it may still be helping against AVF device creation failures.
298                 read_timeout=14,
299                 logger=FilteredLogger(logger, "INFO"),
300             )
301             # The following is needed to prevent union (e.g. Ip4) debug logging
302             # of VPP part of PAPI from spamming robot logs.
303             logging.getLogger("vpp_papi.serializer").setLevel(logging.INFO)
304         finally:
305             if sys.path[-1] == cls.api_package_path:
306                 sys.path.pop()
307         return vpp_instance
308
309     @classmethod
310     def key_for_node_and_socket(cls, node, remote_socket):
311         """Return a hashable object to distinguish nodes.
312
313         The usual node object (of "dict" type) is not hashable,
314         and can contain mutable information (mostly virtual interfaces).
315         Use this method to get an object suitable for being a key in dict.
316
317         The fields to include are chosen by what ssh needs.
318
319         This class method is needed, for disconnect.
320
321         :param node: The node object to distinguish.
322         :param remote_socket: Path to remote socket.
323         :type node: dict
324         :type remote_socket: str
325         :return: Tuple of values distinguishing this node from similar ones.
326         :rtype: tuple of str
327         """
328         return (
329             node["host"],
330             node["port"],
331             remote_socket,
332             # TODO: Do we support sockets paths such as "~/vpp/api.socket"?
333             # If yes, add also:
334             # node[u"username"],
335         )
336
337     def key_for_self(self):
338         """Return a hashable object to distinguish nodes.
339
340         Just a wrapper around key_for_node_and_socket
341         which sets up proper arguments.
342
343         :return: Tuple of values distinguishing this node from similar ones.
344         :rtype: tuple of str
345         """
346         return self.__class__.key_for_node_and_socket(
347             self._node,
348             self._remote_vpp_socket,
349         )
350
351     def set_connected_client(self, client):
352         """Add a connected client instance into cache.
353
354         This hides details of what the node key is.
355
356         If there already is a client for the computed key,
357         fail, as it is a sign of resource leakage.
358
359         :param client: VPP client instance in connected state.
360         :type client: vpp_papi.VPPApiClient
361         :raises RuntimeError: If related key already has a cached client.
362         """
363         key = self.key_for_self()
364         cache = self.__class__.conn_cache
365         if key in cache:
366             raise RuntimeError(f"Caching client with existing key: {key}")
367         cache[key] = client
368
369     def get_connected_client(self, check_connected=True):
370         """Return None or cached connected client.
371
372         If check_connected, RuntimeError is raised when the client is
373         not in cache. None is returned if client is not in cache
374         (and the check is disabled).
375         Successful retrieval from cache is logged only when check_connected.
376
377         This hides details of what the node key is.
378
379         :param check_connected: Whether cache miss raises (and success logs).
380         :type check_connected: bool
381         :returns: Connected client instance, or None if uncached and no check.
382         :rtype: Optional[vpp_papi.VPPApiClient]
383         :raises RuntimeError: If cache miss and check enabled.
384         """
385         key = self.key_for_self()
386         ret = self.__class__.conn_cache.get(key, None)
387         if check_connected:
388             if ret is None:
389                 raise RuntimeError(f"Client not cached for key: {key}")
390             # When reading logs, it is good to see which VPP is accessed.
391             logger.debug(f"Activated cached PAPI client for key: {key}")
392         return ret
393
394     def __enter__(self):
395         """Create a tunnel, connect VPP instance.
396
397         If the connected client is in cache, return it.
398         Only if not, create a new (or reuse a disconnected) client instance.
399
400         Only at this point a local socket names are created
401         in a temporary directory, as CSIT can connect to multiple VPPs.
402
403         The following attributes are added to the client instance
404         to simplify caching and cleanup:
405         csit_temp_dir
406             - Temporary socket files are created here.
407         csit_control_socket
408             - This socket controls the local ssh process doing the forwarding.
409         csit_local_vpp_socket
410             - This is the forwarded socket to talk with remote VPP.
411         csit_deque
412             - Queue for responses.
413
414         The attribute names do not start with underscore,
415         so pylint does not complain about accessing private attribute.
416         The attribute names start with csit_ to avoid naming conflicts
417         with "real" attributes from VPP Python code.
418
419         :returns: self
420         :rtype: PapiSocketExecutor
421         """
422         # Do we have the connected instance in the cache?
423         vpp_instance = self.get_connected_client(check_connected=False)
424         if vpp_instance is not None:
425             return self
426         # No luck, create and connect a new instance.
427         time_enter = time.monotonic()
428         node = self._node
429         # Parsing takes longer than connecting, prepare instance before tunnel.
430         vpp_instance = self.ensure_vpp_instance()
431         # Store into cache as soon as possible.
432         # If connection fails, it is better to attempt disconnect anyway.
433         self.set_connected_client(vpp_instance)
434         # Set additional attributes.
435         vpp_instance.csit_temp_dir = tempfile.TemporaryDirectory(dir="/tmp")
436         temp_path = vpp_instance.csit_temp_dir.name
437         api_socket = temp_path + "/vpp-api.sock"
438         vpp_instance.csit_local_vpp_socket = api_socket
439         ssh_socket = temp_path + "/ssh.sock"
440         vpp_instance.csit_control_socket = ssh_socket
441         # Cleanup possibilities.
442         ret_code, _ = run(["ls", ssh_socket], check=False)
443         if ret_code != 2:
444             # This branch never seems to be hit in CI,
445             # but may be useful when testing manually.
446             run(
447                 ["ssh", "-S", ssh_socket, "-O", "exit", "0.0.0.0"],
448                 check=False,
449                 log=True,
450             )
451             # TODO: Is any sleep necessary? How to prove if not?
452             run(["sleep", "0.1"])
453             run(["rm", "-vrf", ssh_socket])
454         # Even if ssh can perhaps reuse this file,
455         # we need to remove it for readiness detection to work correctly.
456         run(["rm", "-rvf", api_socket])
457         # We use sleep command. The ssh command will exit in 30 second,
458         # unless a local socket connection is established,
459         # in which case the ssh command will exit only when
460         # the ssh connection is closed again (via control socket).
461         # The log level is to suppress "Warning: Permanently added" messages.
462         ssh_cmd = [
463             "ssh",
464             "-S",
465             ssh_socket,
466             "-M",
467             "-L",
468             f"{api_socket}:{self._remote_vpp_socket}",
469             "-p",
470             str(node["port"]),
471             "-o",
472             "LogLevel=ERROR",
473             "-o",
474             "UserKnownHostsFile=/dev/null",
475             "-o",
476             "StrictHostKeyChecking=no",
477             "-o",
478             "ExitOnForwardFailure=yes",
479             f"{node['username']}@{node['host']}",
480             "sleep",
481             "30",
482         ]
483         priv_key = node.get("priv_key")
484         if priv_key:
485             # This is tricky. We need a file to pass the value to ssh command.
486             # And we need ssh command, because paramiko does not support sockets
487             # (neither ssh_socket, nor _remote_vpp_socket).
488             key_file = tempfile.NamedTemporaryFile()
489             key_file.write(priv_key)
490             # Make sure the content is written, but do not close yet.
491             key_file.flush()
492             ssh_cmd[1:1] = ["-i", key_file.name]
493         password = node.get("password")
494         if password:
495             # Prepend sshpass command to set password.
496             ssh_cmd[:0] = ["sshpass", "-p", password]
497         time_stop = time.monotonic() + 10.0
498         # subprocess.Popen seems to be the best way to run commands
499         # on background. Other ways (shell=True with "&" and ssh with -f)
500         # seem to be too dependent on shell behavior.
501         # In particular, -f does NOT return values for run().
502         subprocess.Popen(ssh_cmd)
503         # Check socket presence on local side.
504         while time.monotonic() < time_stop:
505             # It can take a moment for ssh to create the socket file.
506             ret_code, _ = run(["ls", "-l", api_socket], check=False)
507             if not ret_code:
508                 break
509             time.sleep(0.01)
510         else:
511             raise RuntimeError("Local side socket has not appeared.")
512         if priv_key:
513             # Socket up means the key has been read. Delete file by closing it.
514             key_file.close()
515         # Everything is ready, set the local socket address and connect.
516         vpp_instance.transport.server_address = api_socket
517         # It seems we can get read error even if every preceding check passed.
518         # Single retry seems to help. TODO: Confirm this is still needed.
519         for _ in range(2):
520             try:
521                 vpp_instance.connect("csit_socket", do_async=True)
522             except (IOError, struct.error) as err:
523                 logger.warn(f"Got initial connect error {err!r}")
524                 vpp_instance.disconnect()
525             else:
526                 break
527         else:
528             raise RuntimeError("Failed to connect to VPP over a socket.")
529         # Only after rls2302 all relevant VPP builds should have do_async.
530         if hasattr(vpp_instance.transport, "do_async"):
531             deq = deque()
532             vpp_instance.csit_deque = deq
533             vpp_instance.register_event_callback(lambda x, y: deq.append(y))
534         else:
535             vpp_instance.csit_deque = None
536         duration_conn = time.monotonic() - time_enter
537         logger.trace(f"Establishing socket connection took {duration_conn}s.")
538         return self
539
540     def __exit__(self, exc_type, exc_val, exc_tb):
541         """No-op, the client instance remains in cache in connected state."""
542
543     @classmethod
544     def disconnect_by_key(cls, key):
545         """Disconnect a connected client instance, noop it not connected.
546
547         Also remove the local sockets by deleting the temporary directory.
548         Put disconnected client instances to the reuse list.
549         The added attributes are not cleaned up,
550         as their values will get overwritten on next connect.
551
552         This method is useful for disconnect_all type of work.
553
554         :param key: Tuple identifying the node (and socket).
555         :type key: tuple of str
556         """
557         client_instance = cls.conn_cache.get(key, None)
558         if client_instance is None:
559             return
560         logger.debug(f"Disconnecting by key: {key}")
561         client_instance.disconnect()
562         run(
563             [
564                 "ssh",
565                 "-S",
566                 client_instance.csit_control_socket,
567                 "-O",
568                 "exit",
569                 "0.0.0.0",
570             ],
571             check=False,
572         )
573         # Temp dir has autoclean, but deleting explicitly
574         # as an error can happen.
575         try:
576             client_instance.csit_temp_dir.cleanup()
577         except FileNotFoundError:
578             # There is a race condition with ssh removing its ssh.sock file.
579             # Single retry should be enough to ensure the complete removal.
580             shutil.rmtree(client_instance.csit_temp_dir.name)
581         # Finally, put disconnected clients to reuse list.
582         cls.reusable_vpp_client_list.append(client_instance)
583         # Invalidate cache last. Repeated errors are better than silent leaks.
584         del cls.conn_cache[key]
585
586     @classmethod
587     def disconnect_by_node_and_socket(
588         cls, node, remote_socket=Constants.SOCKSVR_PATH
589     ):
590         """Disconnect a connected client instance, noop it not connected.
591
592         Also remove the local sockets by deleting the temporary directory.
593         Put disconnected client instances to the reuse list.
594         The added attributes are not cleaned up,
595         as their values will get overwritten on next connect.
596
597         Call this method just before killing/restarting remote VPP instance.
598         """
599         key = cls.key_for_node_and_socket(node, remote_socket)
600         return cls.disconnect_by_key(key)
601
602     @classmethod
603     def disconnect_all_sockets_by_node(cls, node):
604         """Disconnect all socket connected client instance.
605
606         Noop if not connected.
607
608         Also remove the local sockets by deleting the temporary directory.
609         Put disconnected client instances to the reuse list.
610         The added attributes are not cleaned up,
611         as their values will get overwritten on next connect.
612
613         Call this method just before killing/restarting remote VPP instance.
614         """
615         sockets = Topology.get_node_sockets(node, socket_type=SocketType.PAPI)
616         if sockets:
617             for socket in sockets.values():
618                 # TODO: Remove sockets from topology.
619                 PapiSocketExecutor.disconnect_by_node_and_socket(node, socket)
620         # Always attempt to disconnect the default socket.
621         return cls.disconnect_by_node_and_socket(node)
622
623     @staticmethod
624     def disconnect_all_papi_connections():
625         """Disconnect all connected client instances, tear down the SSH tunnels.
626
627         Also remove the local sockets by deleting the temporary directory.
628         Put disconnected client instances to the reuse list.
629         The added attributes are not cleaned up,
630         as their values will get overwritten on next connect.
631
632         This should be a class method,
633         but we prefer to call static methods from Robot.
634
635         Call this method just before killing/restarting all VPP instances.
636         """
637         cls = PapiSocketExecutor
638         # Iterate over copy of entries so deletions do not mess with iterator.
639         keys_copy = list(cls.conn_cache.keys())
640         for key in keys_copy:
641             cls.disconnect_by_key(key)
642
643     def add(self, csit_papi_command, history=True, **kwargs):
644         """Add next command to internal command list; return self.
645
646         Unless disabled, new entry to papi history is also added at this point.
647         The kwargs dict is serialized or deep-copied, so it is safe to use
648         the original with partial modifications for subsequent calls.
649
650         Any pending conflicts from .api.json processing are raised.
651         Then the command name is checked for known CRCs.
652         Unsupported commands raise an exception, as CSIT change
653         should not start using messages without making sure which CRCs
654         are supported.
655         Each CRC issue is raised only once, so subsequent tests
656         can raise other issues.
657
658         With async handling mode, this method also serializes and sends
659         the command, skips CRC check to gain speed, and saves memory
660         by putting a sentinel (instead of deepcopy) to api command list.
661
662         For scale tests, the call sites are responsible to set history values
663         in a way that hints what is done without overwhelming the papi history.
664
665         Note to contributors: Do not rename "csit_papi_command"
666         to anything VPP could possibly use as an API field name.
667
668         :param csit_papi_command: VPP API command.
669         :param history: Enable/disable adding command to PAPI command history.
670         :param kwargs: Optional key-value arguments.
671         :type csit_papi_command: str
672         :type history: bool
673         :type kwargs: dict
674         :returns: self, so that method chaining is possible.
675         :rtype: PapiSocketExecutor
676         :raises RuntimeError: If unverified or conflicting CRC is encountered.
677         """
678         self.crc_checker.report_initial_conflicts()
679         if history:
680             # No need for deepcopy yet, serialization isolates from edits.
681             PapiHistory.add_to_papi_history(
682                 self._node, csit_papi_command, **kwargs
683             )
684         self.crc_checker.check_api_name(csit_papi_command)
685         if self._is_async:
686             # Save memory but still count the number of expected replies.
687             self._api_command_list.append(0)
688             api_object = self.get_connected_client(check_connected=False).api
689             func = getattr(api_object, csit_papi_command)
690             # No need for deepcopy yet, serialization isolates from edits.
691             func(**kwargs)
692         else:
693             # No serialization, so deepcopy is needed here.
694             self._api_command_list.append(
695                 dict(api_name=csit_papi_command, api_args=copy.deepcopy(kwargs))
696             )
697         return self
698
699     def get_replies(self, err_msg="Failed to get replies."):
700         """Get reply for each command from VPP Python API.
701
702         This method expects one reply per command,
703         and gains performance by reading replies only after
704         sending all commands.
705
706         The replies are parsed into dict-like objects,
707         "retval" field (if present) is guaranteed to be zero on success.
708
709         Do not use this for messages with variable number of replies,
710         use get_details instead.
711         Do not use for commands trigering VPP-2033,
712         use series of get_reply instead.
713
714         :param err_msg: The message used if the PAPI command(s) execution fails.
715         :type err_msg: str
716         :returns: Responses, dict objects with fields due to API and "retval".
717         :rtype: list of dict
718         :raises RuntimeError: If retval is nonzero, parsing or ssh error.
719         """
720         if not self._is_async:
721             raise RuntimeError("Sync handling does not suport get_replies.")
722         return self._execute(err_msg=err_msg, do_async=True)
723
724     def get_reply(self, err_msg="Failed to get reply."):
725         """Get reply to single command from VPP Python API.
726
727         This method waits for a single reply (no control ping),
728         thus avoiding bugs like VPP-2033.
729
730         The reply is parsed into a dict-like object,
731         "retval" field (if present) is guaranteed to be zero on success.
732
733         :param err_msg: The message used if the PAPI command(s) execution fails.
734         :type err_msg: str
735         :returns: Response, dict object with fields due to API and "retval".
736         :rtype: dict
737         :raises AssertionError: If retval is nonzero, parsing or ssh error.
738         """
739         if self._is_async:
740             raise RuntimeError("Async handling does not suport get_reply.")
741         replies = self._execute(err_msg=err_msg, do_async=False)
742         if len(replies) != 1:
743             raise RuntimeError(f"Expected single reply, got {replies!r}")
744         return replies[0]
745
746     def get_sw_if_index(self, err_msg="Failed to get reply."):
747         """Get sw_if_index from reply from VPP Python API.
748
749         Frequently, the caller is only interested in sw_if_index field
750         of the reply, this wrapper around get_reply (thus safe against VPP-2033)
751         makes such call sites shorter.
752
753         :param err_msg: The message used if the PAPI command(s) execution fails.
754         :type err_msg: str
755         :returns: Response, sw_if_index value of the reply.
756         :rtype: int
757         :raises AssertionError: If retval is nonzero, parsing or ssh error.
758         """
759         if self._is_async:
760             raise RuntimeError("Async handling does not suport get_sw_if_index")
761         reply = self.get_reply(err_msg=err_msg)
762         return reply["sw_if_index"]
763
764     def get_details(self, err_msg="Failed to get dump details."):
765         """Get details (for possibly multiple dumps) from VPP Python API.
766
767         The details are parsed into dict-like objects.
768         The number of details per single dump command can vary,
769         and all association between details and dumps is lost,
770         so if you care about the association (as opposed to
771         logging everything at once for debugging purposes),
772         it is recommended to call get_details for each dump (type) separately.
773
774         This method uses control ping to detect end of replies,
775         so it is not suitable for commands which trigger VPP-2033
776         (but arguably no dump currently triggers it).
777
778         :param err_msg: The message used if the PAPI command(s) execution fails.
779         :type err_msg: str
780         :returns: Details, dict objects with fields due to API without "retval".
781         :rtype: list of dict
782         """
783         if self._is_async:
784             raise RuntimeError("Async handling does not suport get_details.")
785         return self._execute(err_msg, do_async=False, single_reply=False)
786
787     @staticmethod
788     def run_cli_cmd(
789         node, cli_cmd, log=True, remote_vpp_socket=Constants.SOCKSVR_PATH
790     ):
791         """Run a CLI command as cli_inband, return the "reply" field of reply.
792
793         Optionally, log the field value.
794         This is a convenience wrapper around get_reply.
795
796         :param node: Node to run command on.
797         :param cli_cmd: The CLI command to be run on the node.
798         :param remote_vpp_socket: Path to remote socket to tunnel to.
799         :param log: If True, the response is logged.
800         :type node: dict
801         :type remote_vpp_socket: str
802         :type cli_cmd: str
803         :type log: bool
804         :returns: CLI output.
805         :rtype: str
806         """
807         cmd = "cli_inband"
808         args = dict(cmd=cli_cmd)
809         err_msg = (
810             f"Failed to run 'cli_inband {cli_cmd}' PAPI command"
811             f" on host {node['host']}"
812         )
813
814         with PapiSocketExecutor(node, remote_vpp_socket) as papi_exec:
815             reply = papi_exec.add(cmd, **args).get_reply(err_msg)["reply"]
816         if log:
817             logger.info(
818                 f"{cli_cmd} ({node['host']} - {remote_vpp_socket}):\n"
819                 f"{reply.strip()}"
820             )
821         return reply
822
823     @staticmethod
824     def run_cli_cmd_on_all_sockets(node, cli_cmd, log=True):
825         """Run a CLI command as cli_inband, on all sockets in topology file.
826
827         Just a run_cli_cmd, looping over sockets.
828
829         :param node: Node to run command on.
830         :param cli_cmd: The CLI command to be run on the node.
831         :param log: If True, the response is logged.
832         :type node: dict
833         :type cli_cmd: str
834         :type log: bool
835         """
836         sockets = Topology.get_node_sockets(node, socket_type=SocketType.PAPI)
837         if sockets:
838             for socket in sockets.values():
839                 PapiSocketExecutor.run_cli_cmd(
840                     node, cli_cmd, log=log, remote_vpp_socket=socket
841                 )
842
843     @staticmethod
844     def dump_and_log(node, cmds):
845         """Dump and log requested information, return None.
846
847         Just a get_details (with logging), looping over commands.
848
849         :param node: DUT node.
850         :param cmds: Dump commands to be executed.
851         :type node: dict
852         :type cmds: list of str
853         """
854         with PapiSocketExecutor(node) as papi_exec:
855             for cmd in cmds:
856                 dump = papi_exec.add(cmd).get_details()
857                 logger.debug(f"{cmd}:\n{pformat(dump)}")
858
859     @staticmethod
860     def _read_internal(vpp_instance, timeout=None):
861         """Blockingly read within timeout.
862
863         This covers behaviors both before and after 37758.
864         One read attempt is guaranteed even with zero timeout.
865
866         TODO: Simplify after 2302 RCA is done.
867
868         :param vpp_instance: Client instance to read from.
869         :param timeout: How long to wait for reply (or transport default).
870         :type vpp_instance: vpp_papi.VPPApiClient
871         :type timeout: Optional[float]
872         :returns: Message read or None if nothing got read.
873         :rtype: Optional[namedtuple]
874         """
875         timeout = vpp_instance.read_timeout if timeout is None else timeout
876         if vpp_instance.csit_deque is None:
877             return vpp_instance.read_blocking(timeout=timeout)
878         time_stop = time.monotonic() + timeout
879         while 1:
880             try:
881                 return vpp_instance.csit_deque.popleft()
882             except IndexError:
883                 # We could busy-wait but that seems to starve the reader thread.
884                 time.sleep(0.01)
885             if time.monotonic() > time_stop:
886                 return None
887
888     @staticmethod
889     def _read(vpp_instance, tries=3):
890         """Blockingly read within timeout, retry on early None.
891
892         For (sometimes) unknown reasons, VPP client in async mode likes
893         to return None occasionally before time runs out.
894         This function retries in that case.
895
896         Most of the time, early None means VPP crashed (see VPP-2033),
897         but is is better to give VPP more chances to respond without failure.
898
899         TODO: Perhaps CSIT now never triggers VPP-2033,
900         so investigate and remove this layer if even more speed is needed.
901
902         :param vpp_instance: Client instance to read from.
903         :param tries: Maximum number of tries to attempt.
904         :type vpp_instance: vpp_papi.VPPApiClient
905         :type tries: int
906         :returns: Message read or None if nothing got read even with retries.
907         :rtype: Optional[namedtuple]
908         """
909         timeout = vpp_instance.read_timeout
910         for _ in range(tries):
911             time_stop = time.monotonic() + 0.9 * timeout
912             reply = PapiSocketExecutor._read_internal(vpp_instance)
913             if reply is None and time.monotonic() < time_stop:
914                 logger.trace("Early None. Retry?")
915                 continue
916             return reply
917         logger.trace(f"Got {tries} early Nones, probably a real None.")
918         return None
919
920     @staticmethod
921     def _drain(vpp_instance, err_msg, timeout=30.0):
922         """Keep reading with until None or timeout.
923
924         This is needed to mitigate the risk of a state with unread responses
925         (e.g. after non-zero retval in the middle of get_replies)
926         causing failures in everything subsequent (until disconnect).
927
928         The reads are done without any waiting.
929
930         It is possible some responses have not arrived yet,
931         but that is unlikely as Python is usually slower than VPP.
932
933         :param vpp_instance: Client instance to read from.
934         :param err_msg: Error message to use when overstepping timeout.
935         :param timeout: How long to try before giving up.
936         :type vpp_instance: vpp_papi.VPPApiClient
937         :type err_msg: str
938         :type timeout: float
939         :raises RuntimeError: If read keeps returning nonzero after timeout.
940         """
941         time_stop = time.monotonic() + timeout
942         while time.monotonic() < time_stop:
943             if PapiSocketExecutor._read_internal(vpp_instance, 0.0) is None:
944                 return
945         raise RuntimeError(f"{err_msg}\nTimed out while draining.")
946
947     def _execute(self, err_msg, do_async, single_reply=True):
948         """Turn internal command list into data and execute; return replies.
949
950         This method also clears the internal command list.
951
952         :param err_msg: The message used if the PAPI command(s) execution fails.
953         :param do_async: If true, assume one reply per command and do not wait
954             for each reply before sending next request.
955             Dump commands (and calls causing VPP-2033) need False.
956         :param single_reply: For sync emulation mode (cannot be False
957             if do_async is True). When false use control ping.
958             When true, wait for a single reply.
959         :type err_msg: str
960         :type do_async: bool
961         :type single_reply: bool
962         :returns: Papi replies parsed into a dict-like object,
963             with fields due to API (possibly including retval).
964         :rtype: NoneType or list of dict
965         :raises RuntimeError: If the replies are not all correct.
966         """
967         local_list = self._api_command_list
968         # Clear first as execution may fail.
969         self._api_command_list = list()
970         if do_async:
971             if not single_reply:
972                 raise RuntimeError("Async papi needs one reply per request.")
973             return self._execute_async(local_list, err_msg=err_msg)
974         return self._execute_sync(
975             local_list, err_msg=err_msg, single_reply=single_reply
976         )
977
978     def _execute_sync(self, local_list, err_msg, single_reply):
979         """Execute commands waiting for replies one by one; return replies.
980
981         This implementation either expects a single response per request,
982         or uses control ping to emulate sync PAPI calls.
983         Reliable, but slow. Required for dumps. Needed for calls
984         which trigger VPP-2033.
985
986         CRC checking is done for the replies (requests are checked in .add).
987
988         :param local_list: The list of PAPI commands to be executed on the node.
989         :param err_msg: The message used if the PAPI command(s) execution fails.
990         :param single_reply: When false use control ping.
991             When true, wait for a single reply.
992         :type local_list: list of dict
993         :type err_msg: str
994         :type single_reply: bool
995         :returns: Papi replies parsed into a dict-like object,
996             with fields due to API (possibly including retval).
997         :rtype: List[UserDict]
998         :raises AttributeError: If VPP does not know the command.
999         :raises RuntimeError: If the replies are not all correct.
1000         """
1001         vpp_instance = self.get_connected_client()
1002         control_ping_fn = getattr(vpp_instance.api, "control_ping")
1003         ret_list = list()
1004         for command in local_list:
1005             api_name = command["api_name"]
1006             papi_fn = getattr(vpp_instance.api, api_name)
1007             replies = list()
1008             try:
1009                 # Send the command maybe followed by control ping.
1010                 main_context = papi_fn(**command["api_args"])
1011                 if single_reply:
1012                     replies.append(PapiSocketExecutor._read(vpp_instance))
1013                 else:
1014                     ping_context = control_ping_fn()
1015                     # Receive the replies.
1016                     while 1:
1017                         reply = PapiSocketExecutor._read(vpp_instance)
1018                         if reply is None:
1019                             raise RuntimeError(
1020                                 f"{err_msg}\nSync PAPI timed out."
1021                             )
1022                         if reply.context == ping_context:
1023                             break
1024                         if reply.context != main_context:
1025                             raise RuntimeError(
1026                                 f"{err_msg}\nUnexpected context: {reply!r}"
1027                             )
1028                         replies.append(reply)
1029             except (AttributeError, IOError, struct.error) as err:
1030                 # TODO: Add retry if it is still needed.
1031                 raise AssertionError(f"{err_msg}") from err
1032             finally:
1033                 # Discard any unprocessed replies to avoid secondary failures.
1034                 PapiSocketExecutor._drain(vpp_instance, err_msg)
1035             # Process replies for this command.
1036             for reply in replies:
1037                 self.crc_checker.check_api_name(reply.__class__.__name__)
1038                 dictized_reply = dictize_and_check_retval(reply, err_msg)
1039                 ret_list.append(dictized_reply)
1040         return ret_list
1041
1042     def _execute_async(self, local_list, err_msg):
1043         """Read, process and return replies.
1044
1045         The messages were already sent by .add() in this mode,
1046         local_list is used just so we know how many replies to read.
1047
1048         Beware: It is not clear what to do when socket read fails
1049         in the middle of async processing.
1050
1051         The implementation assumes each command results in exactly one reply,
1052         there is no reordering in either commands nor replies,
1053         and context numbers increase one by one (and are matching for replies).
1054
1055         To speed processing up, reply CRC values are not checked.
1056
1057         The current implementation does not limit the number of messages
1058         in-flight, we rely on VPP PAPI background thread to move replies
1059         from socket to queue fast enough.
1060
1061         :param local_list: The list of PAPI commands to get replies for.
1062         :param err_msg: The message used if the PAPI command(s) execution fails.
1063         :type local_list: list
1064         :type err_msg: str
1065         :returns: Papi replies parsed into a dict-like object, with fields
1066             according to API (possibly including retval).
1067         :rtype: List[UserDict]
1068         :raises RuntimeError: If the replies are not all correct.
1069         """
1070         vpp_instance = self.get_connected_client()
1071         ret_list = list()
1072         try:
1073             for index, _ in enumerate(local_list):
1074                 # Blocks up to timeout.
1075                 reply = PapiSocketExecutor._read(vpp_instance)
1076                 if reply is None:
1077                     time_msg = f"PAPI async timeout: idx {index}"
1078                     raise RuntimeError(f"{err_msg}\n{time_msg}")
1079                 ret_list.append(dictize_and_check_retval(reply, err_msg))
1080         finally:
1081             # Discard any unprocessed replies to avoid secondary failures.
1082             PapiSocketExecutor._drain(vpp_instance, err_msg)
1083         return ret_list
1084
1085
1086 class Disconnector:
1087     """Class for holding a single keyword."""
1088
1089     @staticmethod
1090     def disconnect_all_papi_connections():
1091         """Disconnect all connected client instances, tear down the SSH tunnels.
1092
1093         Also remove the local sockets by deleting the temporary directory.
1094         Put disconnected client instances to the reuse list.
1095         The added attributes are not cleaned up,
1096         as their values will get overwritten on next connect.
1097
1098         Call this method just before killing/restarting all VPP instances.
1099
1100         This could be a class method of PapiSocketExecutor.
1101         But Robot calls methods on instances, and it would be weird
1102         to give node argument for constructor in import.
1103         Also, as we have a class of the same name as the module,
1104         the keywords defined on module level are not accessible.
1105         """
1106         cls = PapiSocketExecutor
1107         # Iterate over copy of entries so deletions do not mess with iterator.
1108         for key in list(cls.conn_cache.keys()):
1109             cls.disconnect_by_key(key)
1110
1111
1112 class PapiExecutor:
1113     """Contains methods for executing VPP Python API commands on DUTs.
1114
1115     TODO: Remove .add step, make get_stats accept paths directly.
1116
1117     This class processes only one type of VPP PAPI methods: vpp-stats.
1118
1119     The recommended ways of use are (examples):
1120
1121     path = ['^/if', '/err/ip4-input', '/sys/node/ip4-input']
1122     with PapiExecutor(node) as papi_exec:
1123         stats = papi_exec.add(api_name='vpp-stats', path=path).get_stats()
1124
1125     print('RX interface core 0, sw_if_index 0:\n{0}'.\
1126         format(stats[0]['/if/rx'][0][0]))
1127
1128     or
1129
1130     path_1 = ['^/if', ]
1131     path_2 = ['^/if', '/err/ip4-input', '/sys/node/ip4-input']
1132     with PapiExecutor(node) as papi_exec:
1133         stats = papi_exec.add('vpp-stats', path=path_1).\
1134             add('vpp-stats', path=path_2).get_stats()
1135
1136     print('RX interface core 0, sw_if_index 0:\n{0}'.\
1137         format(stats[1]['/if/rx'][0][0]))
1138
1139     Note: In this case, when PapiExecutor method 'add' is used:
1140     - its parameter 'csit_papi_command' is used only to keep information
1141       that vpp-stats are requested. It is not further processed but it is
1142       included in the PAPI history this way:
1143       vpp-stats(path=['^/if', '/err/ip4-input', '/sys/node/ip4-input'])
1144       Always use csit_papi_command="vpp-stats" if the VPP PAPI method
1145       is "stats".
1146     - the second parameter must be 'path' as it is used by PapiExecutor
1147       method 'add'.
1148     - even if the parameter contains multiple paths, there is only one
1149       reply item (for each .add).
1150     """
1151
1152     def __init__(self, node):
1153         """Initialization.
1154
1155         :param node: Node to run command(s) on.
1156         :type node: dict
1157         """
1158         # Node to run command(s) on.
1159         self._node = node
1160
1161         # The list of PAPI commands to be executed on the node.
1162         self._api_command_list = list()
1163
1164         self._ssh = SSH()
1165
1166     def __enter__(self):
1167         try:
1168             self._ssh.connect(self._node)
1169         except IOError as err:
1170             msg = f"PAPI: Cannot open SSH connection to {self._node['host']}"
1171             raise RuntimeError(msg) from err
1172         return self
1173
1174     def __exit__(self, exc_type, exc_val, exc_tb):
1175         self._ssh.disconnect(self._node)
1176
1177     def add(self, csit_papi_command="vpp-stats", history=True, **kwargs):
1178         """Add next command to internal command list; return self.
1179
1180         The argument name 'csit_papi_command' must be unique enough as it cannot
1181         be repeated in kwargs.
1182         The kwargs dict is deep-copied, so it is safe to use the original
1183         with partial modifications for subsequent commands.
1184
1185         :param csit_papi_command: VPP API command.
1186         :param history: Enable/disable adding command to PAPI command history.
1187         :param kwargs: Optional key-value arguments.
1188         :type csit_papi_command: str
1189         :type history: bool
1190         :type kwargs: dict
1191         :returns: self, so that method chaining is possible.
1192         :rtype: PapiExecutor
1193         """
1194         if history:
1195             PapiHistory.add_to_papi_history(
1196                 self._node, csit_papi_command, **kwargs
1197             )
1198         self._api_command_list.append(
1199             dict(api_name=csit_papi_command, api_args=copy.deepcopy(kwargs))
1200         )
1201         return self
1202
1203     def get_stats(
1204         self,
1205         err_msg="Failed to get statistics.",
1206         timeout=120,
1207         socket=Constants.SOCKSTAT_PATH,
1208     ):
1209         """Get VPP Stats from VPP Python API.
1210
1211         :param err_msg: The message used if the PAPI command(s) execution fails.
1212         :param timeout: Timeout in seconds.
1213         :param socket: Path to Stats socket to tunnel to.
1214         :type err_msg: str
1215         :type timeout: int
1216         :type socket: str
1217         :returns: Requested VPP statistics.
1218         :rtype: list of dict
1219         """
1220         paths = [cmd["api_args"]["path"] for cmd in self._api_command_list]
1221         self._api_command_list = list()
1222
1223         stdout = self._execute_papi(
1224             paths,
1225             method="stats",
1226             err_msg=err_msg,
1227             timeout=timeout,
1228             socket=socket,
1229         )
1230
1231         return json.loads(stdout)
1232
1233     @staticmethod
1234     def _process_api_data(api_d):
1235         """Process API data for smooth converting to JSON string.
1236
1237         Apply binascii.hexlify() method for string values.
1238
1239         :param api_d: List of APIs with their arguments.
1240         :type api_d: list
1241         :returns: List of APIs with arguments pre-processed for JSON.
1242         :rtype: list
1243         """
1244
1245         def process_value(val):
1246             """Process value.
1247
1248             :param val: Value to be processed.
1249             :type val: object
1250             :returns: Processed value.
1251             :rtype: dict or str or int
1252             """
1253             if isinstance(val, dict):
1254                 for val_k, val_v in val.items():
1255                     val[str(val_k)] = process_value(val_v)
1256                 retval = val
1257             elif isinstance(val, list):
1258                 for idx, val_l in enumerate(val):
1259                     val[idx] = process_value(val_l)
1260                 retval = val
1261             else:
1262                 retval = val.encode().hex() if isinstance(val, str) else val
1263             return retval
1264
1265         api_data_processed = list()
1266         for api in api_d:
1267             api_args_processed = dict()
1268             for a_k, a_v in api["api_args"].items():
1269                 api_args_processed[str(a_k)] = process_value(a_v)
1270             api_data_processed.append(
1271                 dict(api_name=api["api_name"], api_args=api_args_processed)
1272             )
1273         return api_data_processed
1274
1275     def _execute_papi(
1276         self, api_data, method="request", err_msg="", timeout=120, socket=None
1277     ):
1278         """Execute PAPI command(s) on remote node and store the result.
1279
1280         :param api_data: List of APIs with their arguments.
1281         :param method: VPP Python API method. Supported methods are: 'request',
1282             'dump' and 'stats'.
1283         :param err_msg: The message used if the PAPI command(s) execution fails.
1284         :param timeout: Timeout in seconds.
1285         :type api_data: list
1286         :type method: str
1287         :type err_msg: str
1288         :type timeout: int
1289         :returns: Stdout from remote python utility, to be parsed by caller.
1290         :rtype: str
1291         :raises SSHTimeout: If PAPI command(s) execution has timed out.
1292         :raises RuntimeError: If PAPI executor failed due to another reason.
1293         :raises AssertionError: If PAPI command(s) execution has failed.
1294         """
1295         if not api_data:
1296             raise RuntimeError("No API data provided.")
1297
1298         json_data = (
1299             json.dumps(api_data)
1300             if method in ("stats", "stats_request")
1301             else json.dumps(self._process_api_data(api_data))
1302         )
1303
1304         sock = f" --socket {socket}" if socket else ""
1305         cmd = (
1306             f"{Constants.REMOTE_FW_DIR}/{Constants.RESOURCES_PAPI_PROVIDER}"
1307             f" --method {method} --data '{json_data}'{sock}"
1308         )
1309         try:
1310             ret_code, stdout, _ = self._ssh.exec_command_sudo(
1311                 cmd=cmd, timeout=timeout, log_stdout_err=False
1312             )
1313         # TODO: Fail on non-empty stderr?
1314         except SSHTimeout:
1315             logger.error(
1316                 f"PAPI command(s) execution timeout on host"
1317                 f" {self._node['host']}:\n{api_data}"
1318             )
1319             raise
1320         except Exception as exc:
1321             raise RuntimeError(
1322                 f"PAPI command(s) execution on host {self._node['host']}"
1323                 f" failed: {api_data}"
1324             ) from exc
1325         if ret_code != 0:
1326             raise AssertionError(err_msg)
1327
1328         return stdout