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