Tolerate failures when setting MTU
[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", history=True, **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 history: Enable/disable adding command to PAPI command history.
301         :param kwargs: Optional key-value arguments.
302         :type csit_papi_command: str
303         :type history: bool
304         :type kwargs: dict
305         :returns: self, so that method chaining is possible.
306         :rtype: PapiExecutor
307         """
308         if history:
309             PapiHistory.add_to_papi_history(
310                 self._node, csit_papi_command, **kwargs)
311         self._api_command_list.append(dict(api_name=csit_papi_command,
312                                            api_args=kwargs))
313         return self
314
315     def get_stats(self, err_msg="Failed to get statistics.", timeout=120):
316         """Get VPP Stats from VPP Python API.
317
318         :param err_msg: The message used if the PAPI command(s) execution fails.
319         :param timeout: Timeout in seconds.
320         :type err_msg: str
321         :type timeout: int
322         :returns: Requested VPP statistics.
323         :rtype: list
324         """
325
326         paths = [cmd['api_args']['path'] for cmd in self._api_command_list]
327         self._api_command_list = list()
328
329         stdout, _ = self._execute_papi(
330             paths, method='stats', err_msg=err_msg, timeout=timeout)
331
332         return json.loads(stdout)
333
334     def get_stats_reply(self, err_msg="Failed to get statistics.", timeout=120):
335         """Get VPP Stats reply from VPP Python API.
336
337         :param err_msg: The message used if the PAPI command(s) execution fails.
338         :param timeout: Timeout in seconds.
339         :type err_msg: str
340         :type timeout: int
341         :returns: Requested VPP statistics.
342         :rtype: list
343         """
344
345         args = self._api_command_list[0]['api_args']
346         self._api_command_list = list()
347
348         stdout, _ = self._execute_papi(
349             args, method='stats_request', err_msg=err_msg, timeout=timeout)
350
351         return json.loads(stdout)
352
353     def get_replies(self, err_msg="Failed to get replies.",
354                     process_reply=True, ignore_errors=False, timeout=120):
355         """Get reply/replies from VPP Python API.
356
357         :param err_msg: The message used if the PAPI command(s) execution fails.
358         :param process_reply: Process PAPI reply if True.
359         :param ignore_errors: If true, the errors in the reply are ignored.
360         :param timeout: Timeout in seconds.
361         :type err_msg: str
362         :type process_reply: bool
363         :type ignore_errors: bool
364         :type timeout: int
365         :returns: Papi response including: papi reply, stdout, stderr and
366             return code.
367         :rtype: PapiResponse
368         """
369         return self._execute(
370             method='request', process_reply=process_reply,
371             ignore_errors=ignore_errors, err_msg=err_msg, timeout=timeout)
372
373     def get_dump(self, err_msg="Failed to get dump.",
374                  process_reply=True, ignore_errors=False, timeout=120):
375         """Get dump from VPP Python API.
376
377         :param err_msg: The message used if the PAPI command(s) execution fails.
378         :param process_reply: Process PAPI reply if True.
379         :param ignore_errors: If true, the errors in the reply are ignored.
380         :param timeout: Timeout in seconds.
381         :type err_msg: str
382         :type process_reply: bool
383         :type ignore_errors: bool
384         :type timeout: int
385         :returns: Papi response including: papi reply, stdout, stderr and
386             return code.
387         :rtype: PapiResponse
388         """
389         return self._execute(
390             method='dump', process_reply=process_reply,
391             ignore_errors=ignore_errors, err_msg=err_msg, timeout=timeout)
392
393     @staticmethod
394     def dump_and_log(node, cmds):
395         """Dump and log requested information.
396
397         :param node: DUT node.
398         :param cmds: Dump commands to be executed.
399         :type node: dict
400         :type cmds: list
401         """
402         with PapiExecutor(node) as papi_exec:
403             for cmd in cmds:
404                 dump = papi_exec.add(cmd).get_dump()
405                 logger.debug("{cmd}:\n{data}".format(
406                     cmd=cmd, data=pformat(dump.reply[0]["api_reply"])))
407
408     @staticmethod
409     def run_cli_cmd(node, cmd, log=True):
410         """Run a CLI command.
411
412         :param node: Node to run command on.
413         :param cmd: The CLI command to be run on the node.
414         :param log: If True, the response is logged.
415         :type node: dict
416         :type cmd: str
417         :type log: bool
418         :returns: Verified data from PAPI response.
419         :rtype: dict
420         """
421
422         cli = 'cli_inband'
423         args = dict(cmd=cmd)
424         err_msg = "Failed to run 'cli_inband {cmd}' PAPI command on host " \
425                   "{host}".format(host=node['host'], cmd=cmd)
426
427         with PapiExecutor(node) as papi_exec:
428             data = papi_exec.add(cli, **args).get_replies(err_msg). \
429                 verify_reply(err_msg=err_msg)
430
431         if log:
432             logger.info("{cmd}:\n{data}".format(cmd=cmd, data=data["reply"]))
433
434         return data
435
436     def execute_should_pass(self, err_msg="Failed to execute PAPI command.",
437                             process_reply=True, ignore_errors=False,
438                             timeout=120):
439         """Execute the PAPI commands and check the return code.
440         Raise exception if the PAPI command(s) failed.
441
442         IMPORTANT!
443         Do not use this method in L1 keywords. Use:
444         - get_replies()
445         - get_dump()
446         This method will be removed soon.
447
448         :param err_msg: The message used if the PAPI command(s) execution fails.
449         :param process_reply: Indicate whether or not to process PAPI reply.
450         :param ignore_errors: If true, the errors in the reply are ignored.
451         :param timeout: Timeout in seconds.
452         :type err_msg: str
453         :type process_reply: bool
454         :type ignore_errors: bool
455         :type timeout: int
456         :returns: Papi response including: papi reply, stdout, stderr and
457             return code.
458         :rtype: PapiResponse
459         :raises AssertionError: If PAPI command(s) execution failed.
460         """
461         # TODO: Migrate callers to get_replies and delete this method.
462         return self.get_replies(
463             process_reply=process_reply, ignore_errors=ignore_errors,
464             err_msg=err_msg, timeout=timeout)
465
466     @staticmethod
467     def _process_api_data(api_d):
468         """Process API data for smooth converting to JSON string.
469
470         Apply binascii.hexlify() method for string values.
471
472         :param api_d: List of APIs with their arguments.
473         :type api_d: list
474         :returns: List of APIs with arguments pre-processed for JSON.
475         :rtype: list
476         """
477
478         def process_value(val):
479             """Process value.
480
481             :param val: Value to be processed.
482             :type val: object
483             :returns: Processed value.
484             :rtype: dict or str or int
485             """
486             if isinstance(val, dict):
487                 for val_k, val_v in val.iteritems():
488                     val[str(val_k)] = process_value(val_v)
489                 return val
490             elif isinstance(val, list):
491                 for idx, val_l in enumerate(val):
492                     val[idx] = process_value(val_l)
493                 return val
494             else:
495                 return binascii.hexlify(val) if isinstance(val, str) else val
496
497         api_data_processed = list()
498         for api in api_d:
499             api_args_processed = dict()
500             for a_k, a_v in api["api_args"].iteritems():
501                 api_args_processed[str(a_k)] = process_value(a_v)
502             api_data_processed.append(dict(api_name=api["api_name"],
503                                            api_args=api_args_processed))
504         return api_data_processed
505
506     @staticmethod
507     def _revert_api_reply(api_r):
508         """Process API reply / a part of API reply.
509
510         Apply binascii.unhexlify() method for unicode values.
511
512         TODO: Implement complex solution to process of replies.
513
514         :param api_r: API reply.
515         :type api_r: dict
516         :returns: Processed API reply / a part of API reply.
517         :rtype: dict
518         """
519         def process_value(val):
520             """Process value.
521
522             :param val: Value to be processed.
523             :type val: object
524             :returns: Processed value.
525             :rtype: dict or str or int
526             """
527             if isinstance(val, dict):
528                 for val_k, val_v in val.iteritems():
529                     val[str(val_k)] = process_value(val_v)
530                 return val
531             elif isinstance(val, list):
532                 for idx, val_l in enumerate(val):
533                     val[idx] = process_value(val_l)
534                 return val
535             elif isinstance(val, unicode):
536                 return binascii.unhexlify(val)
537             else:
538                 return val
539
540         reply_dict = dict()
541         reply_value = dict()
542         for reply_key, reply_v in api_r.iteritems():
543             for a_k, a_v in reply_v.iteritems():
544                 reply_value[a_k] = process_value(a_v)
545             reply_dict[reply_key] = reply_value
546         return reply_dict
547
548     def _process_reply(self, api_reply):
549         """Process API reply.
550
551         :param api_reply: API reply.
552         :type api_reply: dict or list of dict
553         :returns: Processed API reply.
554         :rtype: list or dict
555         """
556         if isinstance(api_reply, list):
557             reverted_reply = [self._revert_api_reply(a_r) for a_r in api_reply]
558         else:
559             reverted_reply = self._revert_api_reply(api_reply)
560         return reverted_reply
561
562     def _execute_papi(self, api_data, method='request', err_msg="",
563                       timeout=120):
564         """Execute PAPI command(s) on remote node and store the result.
565
566         :param api_data: List of APIs with their arguments.
567         :param method: VPP Python API method. Supported methods are: 'request',
568             'dump' and 'stats'.
569         :param err_msg: The message used if the PAPI command(s) execution fails.
570         :param timeout: Timeout in seconds.
571         :type api_data: list
572         :type method: str
573         :type err_msg: str
574         :type timeout: int
575         :returns: Stdout and stderr.
576         :rtype: 2-tuple of str
577         :raises SSHTimeout: If PAPI command(s) execution has timed out.
578         :raises RuntimeError: If PAPI executor failed due to another reason.
579         :raises AssertionError: If PAPI command(s) execution has failed.
580         """
581
582         if not api_data:
583             RuntimeError("No API data provided.")
584
585         json_data = json.dumps(api_data) \
586             if method in ("stats", "stats_request") \
587             else json.dumps(self._process_api_data(api_data))
588
589         cmd = "{fw_dir}/{papi_provider} --method {method} --data '{json}'".\
590             format(fw_dir=Constants.REMOTE_FW_DIR,
591                    papi_provider=Constants.RESOURCES_PAPI_PROVIDER,
592                    method=method,
593                    json=json_data)
594         try:
595             ret_code, stdout, stderr = self._ssh.exec_command_sudo(
596                 cmd=cmd, timeout=timeout, log_stdout_err=False)
597         except SSHTimeout:
598             logger.error("PAPI command(s) execution timeout on host {host}:"
599                          "\n{apis}".format(host=self._node["host"],
600                                            apis=api_data))
601             raise
602         except Exception:
603             raise RuntimeError("PAPI command(s) execution on host {host} "
604                                "failed: {apis}".format(host=self._node["host"],
605                                                        apis=api_data))
606         if ret_code != 0:
607             raise AssertionError(err_msg)
608
609         return stdout, stderr
610
611     def _execute(self, method='request', process_reply=True,
612                  ignore_errors=False, err_msg="", timeout=120):
613         """Turn internal command list into proper data and execute; return
614         PAPI response.
615
616         This method also clears the internal command list.
617
618         IMPORTANT!
619         Do not use this method in L1 keywords. Use:
620         - get_stats()
621         - get_replies()
622         - get_dump()
623
624         :param method: VPP Python API method. Supported methods are: 'request',
625             'dump' and 'stats'.
626         :param process_reply: Process PAPI reply if True.
627         :param ignore_errors: If true, the errors in the reply are ignored.
628         :param err_msg: The message used if the PAPI command(s) execution fails.
629         :param timeout: Timeout in seconds.
630         :type method: str
631         :type process_reply: bool
632         :type ignore_errors: bool
633         :type err_msg: str
634         :type timeout: int
635         :returns: Papi response including: papi reply, stdout, stderr and
636             return code.
637         :rtype: PapiResponse
638         :raises KeyError: If the reply is not correct.
639         """
640
641         local_list = self._api_command_list
642
643         # Clear first as execution may fail.
644         self._api_command_list = list()
645
646         stdout, stderr = self._execute_papi(
647             local_list, method=method, err_msg=err_msg, timeout=timeout)
648         papi_reply = list()
649         if process_reply:
650             try:
651                 json_data = json.loads(stdout)
652             except ValueError:
653                 logger.error("An error occured while processing the PAPI "
654                              "request:\n{rqst}".format(rqst=local_list))
655                 raise
656             for data in json_data:
657                 try:
658                     api_reply_processed = dict(
659                         api_name=data["api_name"],
660                         api_reply=self._process_reply(data["api_reply"]))
661                 except KeyError:
662                     if ignore_errors:
663                         continue
664                     else:
665                         raise
666                 papi_reply.append(api_reply_processed)
667
668         # Log processed papi reply to be able to check API replies changes
669         logger.debug("Processed PAPI reply: {reply}".format(reply=papi_reply))
670
671         return PapiResponse(
672             papi_reply=papi_reply, stdout=stdout, stderr=stderr,
673             requests=[rqst["api_name"] for rqst in local_list])