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