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