VAT-to-PAPI: VPPCounters
[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 json
19
20 from pprint import pformat
21
22 from robot.api import logger
23
24 from resources.libraries.python.Constants import Constants
25 from resources.libraries.python.ssh import SSH, SSHTimeout
26 from resources.libraries.python.PapiHistory import PapiHistory
27
28
29 __all__ = ["PapiExecutor", "PapiResponse"]
30
31
32 class PapiResponse(object):
33     """Class for metadata specifying the Papi reply, stdout, stderr and return
34     code.
35     """
36
37     def __init__(self, papi_reply=None, stdout="", stderr="", requests=None):
38         """Construct the Papi response by setting the values needed.
39
40         TODO:
41             Implement 'dump' analogue of verify_replies that would concatenate
42             the values, so that call sites do not have to do that themselves.
43
44         :param papi_reply: API reply from last executed PAPI command(s).
45         :param stdout: stdout from last executed PAPI command(s).
46         :param stderr: stderr from last executed PAPI command(s).
47         :param requests: List of used PAPI requests. It is used while verifying
48             replies. If None, expected replies must be provided for verify_reply
49             and verify_replies methods.
50         :type papi_reply: list or None
51         :type stdout: str
52         :type stderr: str
53         :type requests: list
54         """
55
56         # API reply from last executed PAPI command(s).
57         self.reply = papi_reply
58
59         # stdout from last executed PAPI command(s).
60         self.stdout = stdout
61
62         # stderr from last executed PAPI command(s).
63         self.stderr = stderr
64
65         # List of used PAPI requests.
66         self.requests = requests
67
68         # List of expected PAPI replies. It is used while verifying replies.
69         if self.requests:
70             self.expected_replies = \
71                 ["{rqst}_reply".format(rqst=rqst) for rqst in self.requests]
72
73     def __str__(self):
74         """Return string with human readable description of the PapiResponse.
75
76         :returns: Readable description.
77         :rtype: str
78         """
79         return (
80             "papi_reply={papi_reply},stdout={stdout},stderr={stderr},"
81             "requests={requests}").format(
82                 papi_reply=self.reply, stdout=self.stdout, stderr=self.stderr,
83                 requests=self.requests)
84
85     def __repr__(self):
86         """Return string executable as Python constructor call.
87
88         :returns: Executable constructor call.
89         :rtype: str
90         """
91         return "PapiResponse({str})".format(str=str(self))
92
93     def verify_reply(self, cmd_reply=None, idx=0,
94                      err_msg="Failed to verify PAPI reply."):
95         """Verify and return data from the PAPI response.
96
97         Note: Use only with a simple request / reply command. In this case the
98         PAPI reply includes 'retval' which is checked in this method.
99
100         Do not use with 'dump' and 'vpp-stats' methods.
101
102         Use if PAPI response includes only one command reply.
103
104         Use it this way (preferred):
105
106         with PapiExecutor(node) as papi_exec:
107             data = papi_exec.add('show_version').get_replies().verify_reply()
108
109         or if you must provide the expected reply (not recommended):
110
111         with PapiExecutor(node) as papi_exec:
112             data = papi_exec.add('show_version').get_replies().\
113                 verify_reply('show_version_reply')
114
115         :param cmd_reply: PAPI reply. If None, list of 'requests' should have
116             been provided to the __init__ method as pre-generated list of
117             replies is used in this method in this case.
118             The PapiExecutor._execute() method provides the requests
119             automatically.
120         :param idx: Index to PapiResponse.reply list.
121         :param err_msg: The message used if the verification fails.
122         :type cmd_reply: str
123         :type idx: int
124         :type err_msg: str or None
125         :returns: Verified data from PAPI response.
126         :rtype: dict
127         :raises AssertionError: If the PAPI return value is not 0, so the reply
128             is not valid.
129         :raises KeyError, IndexError: If the reply does not have expected
130             structure.
131         """
132         cmd_rpl = self.expected_replies[idx] if cmd_reply is None else cmd_reply
133
134         data = self.reply[idx]['api_reply'][cmd_rpl]
135         if data['retval'] != 0:
136             raise AssertionError("{msg}\nidx={idx}, cmd_reply={reply}".
137                                  format(msg=err_msg, idx=idx, reply=cmd_rpl))
138
139         return data
140
141     def verify_replies(self, cmd_replies=None,
142                        err_msg="Failed to verify PAPI reply."):
143         """Verify and return data from the PAPI response.
144
145         Note: Use only with request / reply commands. In this case each
146         PAPI reply includes 'retval' which is checked.
147
148         Do not use with 'dump' and 'vpp-stats' methods.
149
150         Use if PAPI response includes more than one command reply.
151
152         Use it this way:
153
154         with PapiExecutor(node) as papi_exec:
155             papi_exec.add(cmd1, **args1).add(cmd2, **args2).add(cmd2, **args3).\
156                 get_replies(err_msg).verify_replies()
157
158         or if you need the data from the PAPI response:
159
160         with PapiExecutor(node) as papi_exec:
161             data = papi_exec.add(cmd1, **args1).add(cmd2, **args2).\
162                 add(cmd2, **args3).get_replies(err_msg).verify_replies()
163
164         or if you must provide the list of expected replies (not recommended):
165
166         with PapiExecutor(node) as papi_exec:
167             data = papi_exec.add(cmd1, **args1).add(cmd2, **args2).\
168                 add(cmd2, **args3).get_replies(err_msg).\
169                 verify_replies(cmd_replies=cmd_replies)
170
171         :param cmd_replies: List of PAPI command replies. If None, list of
172             'requests' should have been provided to the __init__ method as
173             pre-generated list of replies is used in this method in this case.
174             The PapiExecutor._execute() method provides the requests
175             automatically.
176         :param err_msg: The message used if the verification fails.
177         :type cmd_replies: list of str or None
178         :type err_msg: str
179         :returns: List of verified data from PAPI response.
180         :rtype list
181         :raises AssertionError: If the PAPI response does not include at least
182             one of specified command replies.
183         """
184         data = list()
185
186         cmd_rpls = self.expected_replies if cmd_replies is None else cmd_replies
187
188         if len(self.reply) != len(cmd_rpls):
189             raise AssertionError(err_msg)
190         for idx, cmd_reply in enumerate(cmd_rpls):
191             data.append(self.verify_reply(cmd_reply, idx, err_msg))
192
193         return data
194
195
196 class PapiExecutor(object):
197     """Contains methods for executing VPP Python API commands on DUTs.
198
199     Note: Use only with "with" statement, e.g.:
200
201         with PapiExecutor(node) as papi_exec:
202             papi_resp = papi_exec.add('show_version').get_replies(err_msg)
203
204     This class processes three classes of VPP PAPI methods:
205     1. simple request / reply: method='request',
206     2. dump functions: method='dump',
207     3. vpp-stats: method='stats'.
208
209     The recommended ways of use are (examples):
210
211     1. Simple request / reply
212
213     a. One request with no arguments:
214
215         with PapiExecutor(node) as papi_exec:
216             data = papi_exec.add('show_version').get_replies().\
217                 verify_reply()
218
219     b. Three requests with arguments, the second and the third ones are the same
220        but with different arguments.
221
222         with PapiExecutor(node) as papi_exec:
223             data = papi_exec.add(cmd1, **args1).add(cmd2, **args2).\
224                 add(cmd2, **args3).get_replies(err_msg).verify_replies()
225
226     2. Dump functions
227
228         cmd = 'sw_interface_rx_placement_dump'
229         with PapiExecutor(node) as papi_exec:
230             papi_resp = papi_exec.add(cmd, sw_if_index=ifc['vpp_sw_index']).\
231                 get_dump(err_msg)
232
233     3. vpp-stats
234
235         path = ['^/if', '/err/ip4-input', '/sys/node/ip4-input']
236
237         with PapiExecutor(node) as papi_exec:
238             data = papi_exec.add(api_name='vpp-stats', path=path).get_stats()
239
240         print('RX interface core 0, sw_if_index 0:\n{0}'.\
241             format(data[0]['/if/rx'][0][0]))
242
243         or
244
245         path_1 = ['^/if', ]
246         path_2 = ['^/if', '/err/ip4-input', '/sys/node/ip4-input']
247
248         with PapiExecutor(node) as papi_exec:
249             data = papi_exec.add('vpp-stats', path=path_1).\
250                 add('vpp-stats', path=path_2).get_stats()
251
252         print('RX interface core 0, sw_if_index 0:\n{0}'.\
253             format(data[1]['/if/rx'][0][0]))
254
255         Note: In this case, when PapiExecutor method 'add' is used:
256         - its parameter 'csit_papi_command' is used only to keep information
257           that vpp-stats are requested. It is not further processed but it is
258           included in the PAPI history this way:
259           vpp-stats(path=['^/if', '/err/ip4-input', '/sys/node/ip4-input'])
260           Always use csit_papi_command="vpp-stats" if the VPP PAPI method
261           is "stats".
262         - the second parameter must be 'path' as it is used by PapiExecutor
263           method 'add'.
264     """
265
266     def __init__(self, node):
267         """Initialization.
268
269         :param node: Node to run command(s) on.
270         :type node: dict
271         """
272
273         # Node to run command(s) on.
274         self._node = node
275
276         # The list of PAPI commands to be executed on the node.
277         self._api_command_list = list()
278
279         self._ssh = SSH()
280
281     def __enter__(self):
282         try:
283             self._ssh.connect(self._node)
284         except IOError:
285             raise RuntimeError("Cannot open SSH connection to host {host} to "
286                                "execute PAPI command(s)".
287                                format(host=self._node["host"]))
288         return self
289
290     def __exit__(self, exc_type, exc_val, exc_tb):
291         self._ssh.disconnect(self._node)
292
293     def add(self, csit_papi_command="vpp-stats", **kwargs):
294         """Add next command to internal command list; return self.
295
296         The argument name 'csit_papi_command' must be unique enough as it cannot
297         be repeated in kwargs.
298
299         :param csit_papi_command: VPP API command.
300         :param kwargs: Optional key-value arguments.
301         :type csit_papi_command: str
302         :type kwargs: dict
303         :returns: self, so that method chaining is possible.
304         :rtype: PapiExecutor
305         """
306         PapiHistory.add_to_papi_history(self._node, csit_papi_command, **kwargs)
307         self._api_command_list.append(dict(api_name=csit_papi_command,
308                                            api_args=kwargs))
309         return self
310
311     def get_stats(self, err_msg="Failed to get statistics.", timeout=120):
312         """Get VPP Stats from VPP Python API.
313
314         :param err_msg: The message used if the PAPI command(s) execution fails.
315         :param timeout: Timeout in seconds.
316         :type err_msg: str
317         :type timeout: int
318         :returns: Requested VPP statistics.
319         :rtype: list
320         """
321
322         paths = [cmd['api_args']['path'] for cmd in self._api_command_list]
323         self._api_command_list = list()
324
325         stdout, _ = self._execute_papi(
326             paths, method='stats', err_msg=err_msg, timeout=timeout)
327
328         return json.loads(stdout)
329
330     def get_stats_reply(self, err_msg="Failed to get statistics.", timeout=120):
331         """Get VPP Stats reply from VPP Python API.
332
333         :param err_msg: The message used if the PAPI command(s) execution fails.
334         :param timeout: Timeout in seconds.
335         :type err_msg: str
336         :type timeout: int
337         :returns: Requested VPP statistics.
338         :rtype: list
339         """
340
341         args = self._api_command_list[0]['api_args']
342         self._api_command_list = list()
343
344         stdout, _ = self._execute_papi(
345             args, method='stats_request', err_msg=err_msg, timeout=timeout)
346
347         return json.loads(stdout)
348
349     def get_replies(self, err_msg="Failed to get replies.",
350                     process_reply=True, ignore_errors=False, timeout=120):
351         """Get reply/replies from VPP Python API.
352
353         :param err_msg: The message used if the PAPI command(s) execution fails.
354         :param process_reply: Process PAPI reply if True.
355         :param ignore_errors: If true, the errors in the reply are ignored.
356         :param timeout: Timeout in seconds.
357         :type err_msg: str
358         :type process_reply: bool
359         :type ignore_errors: bool
360         :type timeout: int
361         :returns: Papi response including: papi reply, stdout, stderr and
362             return code.
363         :rtype: PapiResponse
364         """
365         return self._execute(
366             method='request', process_reply=process_reply,
367             ignore_errors=ignore_errors, err_msg=err_msg, timeout=timeout)
368
369     def get_dump(self, err_msg="Failed to get dump.",
370                  process_reply=True, ignore_errors=False, timeout=120):
371         """Get dump from VPP Python API.
372
373         :param err_msg: The message used if the PAPI command(s) execution fails.
374         :param process_reply: Process PAPI reply if True.
375         :param ignore_errors: If true, the errors in the reply are ignored.
376         :param timeout: Timeout in seconds.
377         :type err_msg: str
378         :type process_reply: bool
379         :type ignore_errors: bool
380         :type timeout: int
381         :returns: Papi response including: papi reply, stdout, stderr and
382             return code.
383         :rtype: PapiResponse
384         """
385         return self._execute(
386             method='dump', process_reply=process_reply,
387             ignore_errors=ignore_errors, err_msg=err_msg, timeout=timeout)
388
389     @staticmethod
390     def dump_and_log(node, cmds):
391         """Dump and log requested information.
392
393         :param node: DUT node.
394         :param cmds: Dump commands to be executed.
395         :type node: dict
396         :type cmds: list
397         """
398         with PapiExecutor(node) as papi_exec:
399             for cmd in cmds:
400                 dump = papi_exec.add(cmd).get_dump()
401                 logger.debug("{cmd}:\n{data}".format(
402                     cmd=cmd, data=pformat(dump.reply[0]["api_reply"])))
403
404     @staticmethod
405     def run_cli_cmd(node, cmd, log=True):
406         """Run a CLI command.
407
408         :param node: Node to run command on.
409         :param cmd: The CLI command to be run on the node.
410         :param log: If True, the response is logged.
411         :type node: dict
412         :type cmd: str
413         :type log: bool
414         :returns: Verified data from PAPI response.
415         :rtype: dict
416         """
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
423         with PapiExecutor(node) as papi_exec:
424             data = papi_exec.add(cli, **args).get_replies(err_msg). \
425                 verify_reply(err_msg=err_msg)
426
427         if log:
428             logger.info("{cmd}:\n{data}".format(cmd=cmd, data=data["reply"]))
429
430         return data
431
432     def execute_should_pass(self, err_msg="Failed to execute PAPI command.",
433                             process_reply=True, ignore_errors=False,
434                             timeout=120):
435         """Execute the PAPI commands and check the return code.
436         Raise exception if the PAPI command(s) failed.
437
438         IMPORTANT!
439         Do not use this method in L1 keywords. Use:
440         - get_replies()
441         - get_dump()
442         This method will be removed soon.
443
444         :param err_msg: The message used if the PAPI command(s) execution fails.
445         :param process_reply: Indicate whether or not to process PAPI reply.
446         :param ignore_errors: If true, the errors in the reply are ignored.
447         :param timeout: Timeout in seconds.
448         :type err_msg: str
449         :type process_reply: bool
450         :type ignore_errors: bool
451         :type timeout: int
452         :returns: Papi response including: papi reply, stdout, stderr and
453             return code.
454         :rtype: PapiResponse
455         :raises AssertionError: If PAPI command(s) execution failed.
456         """
457         # TODO: Migrate callers to get_replies and delete this method.
458         return self.get_replies(
459             process_reply=process_reply, ignore_errors=ignore_errors,
460             err_msg=err_msg, timeout=timeout)
461
462     @staticmethod
463     def _process_api_data(api_d):
464         """Process API data for smooth converting to JSON string.
465
466         Apply binascii.hexlify() method for string values.
467
468         :param api_d: List of APIs with their arguments.
469         :type api_d: list
470         :returns: List of APIs with arguments pre-processed for JSON.
471         :rtype: list
472         """
473
474         def process_value(val):
475             """Process value.
476
477             :param val: Value to be processed.
478             :type val: object
479             :returns: Processed value.
480             :rtype: dict or str or int
481             """
482             if isinstance(val, dict):
483                 val_dict = dict()
484                 for val_k, val_v in val.iteritems():
485                     val_dict[str(val_k)] = process_value(val_v)
486                 return val_dict
487             else:
488                 return binascii.hexlify(val) if isinstance(val, str) else val
489
490         api_data_processed = list()
491         for api in api_d:
492             api_args_processed = dict()
493             for a_k, a_v in api["api_args"].iteritems():
494                 api_args_processed[str(a_k)] = process_value(a_v)
495             api_data_processed.append(dict(api_name=api["api_name"],
496                                            api_args=api_args_processed))
497         return api_data_processed
498
499     @staticmethod
500     def _revert_api_reply(api_r):
501         """Process API reply / a part of API reply.
502
503         Apply binascii.unhexlify() method for unicode values.
504
505         TODO: Implement complex solution to process of replies.
506
507         :param api_r: API reply.
508         :type api_r: dict
509         :returns: Processed API reply / a part of API reply.
510         :rtype: dict
511         """
512         reply_dict = dict()
513         reply_value = dict()
514         for reply_key, reply_v in api_r.iteritems():
515             for a_k, a_v in reply_v.iteritems():
516                 reply_value[a_k] = binascii.unhexlify(a_v) \
517                     if isinstance(a_v, unicode) else a_v
518             reply_dict[reply_key] = reply_value
519         return reply_dict
520
521     def _process_reply(self, api_reply):
522         """Process API reply.
523
524         :param api_reply: API reply.
525         :type api_reply: dict or list of dict
526         :returns: Processed API reply.
527         :rtype: list or dict
528         """
529         if isinstance(api_reply, list):
530             reverted_reply = [self._revert_api_reply(a_r) for a_r in api_reply]
531         else:
532             reverted_reply = self._revert_api_reply(api_reply)
533         return reverted_reply
534
535     def _execute_papi(self, api_data, method='request', err_msg="",
536                       timeout=120):
537         """Execute PAPI command(s) on remote node and store the result.
538
539         :param api_data: List of APIs with their arguments.
540         :param method: VPP Python API method. Supported methods are: 'request',
541             'dump' and 'stats'.
542         :param err_msg: The message used if the PAPI command(s) execution fails.
543         :param timeout: Timeout in seconds.
544         :type api_data: list
545         :type method: str
546         :type err_msg: str
547         :type timeout: int
548         :returns: Stdout and stderr.
549         :rtype: 2-tuple of str
550         :raises SSHTimeout: If PAPI command(s) execution has timed out.
551         :raises RuntimeError: If PAPI executor failed due to another reason.
552         :raises AssertionError: If PAPI command(s) execution has failed.
553         """
554
555         if not api_data:
556             RuntimeError("No API data provided.")
557
558         json_data = json.dumps(api_data) \
559             if method in ("stats", "stats_request") \
560             else json.dumps(self._process_api_data(api_data))
561
562         cmd = "{fw_dir}/{papi_provider} --method {method} --data '{json}'".\
563             format(fw_dir=Constants.REMOTE_FW_DIR,
564                    papi_provider=Constants.RESOURCES_PAPI_PROVIDER,
565                    method=method,
566                    json=json_data)
567         try:
568             ret_code, stdout, stderr = self._ssh.exec_command_sudo(
569                 cmd=cmd, timeout=timeout, log_stdout_err=False)
570         except SSHTimeout:
571             logger.error("PAPI command(s) execution timeout on host {host}:"
572                          "\n{apis}".format(host=self._node["host"],
573                                            apis=api_data))
574             raise
575         except Exception:
576             raise RuntimeError("PAPI command(s) execution on host {host} "
577                                "failed: {apis}".format(host=self._node["host"],
578                                                        apis=api_data))
579         if ret_code != 0:
580             raise AssertionError(err_msg)
581
582         return stdout, stderr
583
584     def _execute(self, method='request', process_reply=True,
585                  ignore_errors=False, err_msg="", timeout=120):
586         """Turn internal command list into proper data and execute; return
587         PAPI response.
588
589         This method also clears the internal command list.
590
591         IMPORTANT!
592         Do not use this method in L1 keywords. Use:
593         - get_stats()
594         - get_replies()
595         - get_dump()
596
597         :param method: VPP Python API method. Supported methods are: 'request',
598             'dump' and 'stats'.
599         :param process_reply: Process PAPI reply if True.
600         :param ignore_errors: If true, the errors in the reply are ignored.
601         :param err_msg: The message used if the PAPI command(s) execution fails.
602         :param timeout: Timeout in seconds.
603         :type method: str
604         :type process_reply: bool
605         :type ignore_errors: bool
606         :type err_msg: str
607         :type timeout: int
608         :returns: Papi response including: papi reply, stdout, stderr and
609             return code.
610         :rtype: PapiResponse
611         :raises KeyError: If the reply is not correct.
612         """
613
614         local_list = self._api_command_list
615
616         # Clear first as execution may fail.
617         self._api_command_list = list()
618
619         stdout, stderr = self._execute_papi(
620             local_list, method=method, err_msg=err_msg, timeout=timeout)
621         papi_reply = list()
622         if process_reply:
623             try:
624                 json_data = json.loads(stdout)
625             except ValueError:
626                 logger.error("An error occured while processing the PAPI "
627                              "request:\n{rqst}".format(rqst=local_list))
628                 raise
629             for data in json_data:
630                 try:
631                     api_reply_processed = dict(
632                         api_name=data["api_name"],
633                         api_reply=self._process_reply(data["api_reply"]))
634                 except KeyError:
635                     if ignore_errors:
636                         continue
637                     else:
638                         raise
639                 papi_reply.append(api_reply_processed)
640
641         # Log processed papi reply to be able to check API replies changes
642         logger.debug("Processed PAPI reply: {reply}".format(reply=papi_reply))
643
644         return PapiResponse(
645             papi_reply=papi_reply, stdout=stdout, stderr=stderr,
646             requests=[rqst["api_name"] for rqst in local_list])