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