PAPI Executor: Add more desciptive error message
[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 This version supports only simple request / reply VPP API methods.
17
18 TODO:
19  - Implement:
20    - Dump functions
21    - vpp-stats
22
23 """
24
25 import binascii
26 import json
27
28 from robot.api import logger
29
30 from resources.libraries.python.Constants import Constants
31 from resources.libraries.python.ssh import SSH, SSHTimeout
32 from resources.libraries.python.PapiHistory import PapiHistory
33
34
35 __all__ = ["PapiExecutor", "PapiResponse"]
36
37
38 class PapiResponse(object):
39     """Class for metadata specifying the Papi reply, stdout, stderr and return
40     code.
41     """
42
43     def __init__(self, papi_reply=None, stdout="", stderr="", ret_code=None,
44                  requests=None):
45         """Construct the Papi response by setting the values needed.
46
47         :param papi_reply: API reply from last executed PAPI command(s).
48         :param stdout: stdout from last executed PAPI command(s).
49         :param stderr: stderr from last executed PAPI command(s).
50         :param ret_code: ret_code from last executed PAPI command(s).
51         :param requests: List of used PAPI requests. It is used while verifying
52             replies. If None, expected replies must be provided for verify_reply
53             and verify_replies methods.
54         :type papi_reply: list
55         :type stdout: str
56         :type stderr: str
57         :type ret_code: int
58         :type requests: list
59         """
60
61         # API reply from last executed PAPI command(s).
62         self.reply = papi_reply
63
64         # stdout from last executed PAPI command(s).
65         self.stdout = stdout
66
67         # stderr from last executed PAPI command(s).
68         self.stderr = stderr
69
70         # return code from last executed PAPI command(s).
71         self.ret_code = ret_code
72
73         # List of used PAPI requests.
74         self.requests = requests
75
76         # List of expected PAPI replies. It is used while verifying replies.
77         if self.requests:
78             self.expected_replies = \
79                 ["{rqst}_reply".format(rqst=rqst) for rqst in self.requests]
80
81     def __str__(self):
82         """Return string with human readable description of the PapiResponse.
83
84         :returns: Readable description.
85         :rtype: str
86         """
87         return ("papi_reply={papi_reply},"
88                 "stdout={stdout},"
89                 "stderr={stderr},"
90                 "ret_code={ret_code},"
91                 "requests={requests}".
92                 format(papi_reply=self.reply,
93                        stdout=self.stdout,
94                        stderr=self.stderr,
95                        ret_code=self.ret_code,
96                        requests=self.requests))
97
98     def __repr__(self):
99         """Return string executable as Python constructor call.
100
101         :returns: Executable constructor call.
102         :rtype: str
103         """
104         return "PapiResponse({str})".format(str=str(self))
105
106     def verify_reply(self, cmd_reply=None, idx=0,
107                      err_msg="Failed to verify PAPI reply."):
108         """Verify and return data from the PAPI response.
109
110         Note: Use only with a simple request / reply command. In this case the
111         PAPI reply includes 'retval' which is checked in this method.
112
113         Use if PAPI response includes only one command reply.
114
115         Use it this way (preferred):
116
117         with PapiExecutor(node) as papi_exec:
118             data = papi_exec.add('show_version').execute_should_pass().\
119                 verify_reply()
120
121         or if you must provide the expected reply (not recommended):
122
123         with PapiExecutor(node) as papi_exec:
124             data = papi_exec.add('show_version').execute_should_pass().\
125                 verify_reply('show_version_reply')
126
127         :param cmd_reply: PAPI reply. If None, list of 'requests' should have
128             been provided to the __init__ method as pre-generated list of
129             replies is used in this method in this case.
130             The .execute* methods are providing the requests automatically.
131         :param idx: Index to PapiResponse.reply list.
132         :param err_msg: The message used if the verification fails.
133         :type cmd_reply: str
134         :type idx: int
135         :type err_msg: str or None
136         :returns: Verified data from PAPI response.
137         :rtype: dict
138         :raises AssertionError: If the PAPI return value is not 0, so the reply
139             is not valid.
140         :raises KeyError, IndexError: If the reply does not have expected
141             structure.
142         """
143         cmd_rpl = self.expected_replies[idx] if cmd_reply is None else cmd_reply
144
145         data = self.reply[idx]['api_reply'][cmd_rpl]
146         if data['retval'] != 0:
147             raise AssertionError("{msg}\nidx={idx}, cmd_reply={reply}".
148                                  format(msg=err_msg, idx=idx, reply=cmd_rpl))
149
150         return data
151
152     def verify_replies(self, cmd_replies=None,
153                        err_msg="Failed to verify PAPI reply."):
154         """Verify and return data from the PAPI response.
155
156         Note: Use only with request / reply commands. In this case each
157         PAPI reply includes 'retval' which is checked.
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                 execute_should_pass(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).execute_should_pass(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).execute_should_pass(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 .execute* methods are providing the requests automatically.
184         :param err_msg: The message used if the verification fails.
185         :type cmd_replies: list of str or None
186         :type err_msg: str
187         :returns: List of verified data from PAPI response.
188         :rtype list
189         :raises AssertionError: If the PAPI response does not include at least
190             one of specified command replies.
191         """
192         data = list()
193
194         cmd_rpls = self.expected_replies if cmd_replies is None else cmd_replies
195
196         if len(self.reply) != len(cmd_rpls):
197             raise AssertionError(err_msg)
198         for idx, cmd_reply in enumerate(cmd_rpls):
199             data.append(self.verify_reply(cmd_reply, idx, err_msg))
200
201         return data
202
203
204 class PapiExecutor(object):
205     """Contains methods for executing Python API commands on DUTs.
206
207     Use only with "with" statement, e.g.:
208
209     with PapiExecutor(node) as papi_exec:
210         papi_resp = papi_exec.add('show_version').execute_should_pass(err_msg)
211     """
212
213     def __init__(self, node):
214         """Initialization.
215
216         :param node: Node to run command(s) on.
217         :type node: dict
218         """
219
220         # Node to run command(s) on.
221         self._node = node
222
223         # The list of PAPI commands to be executed on the node.
224         self._api_command_list = list()
225
226         self._ssh = SSH()
227
228     def __enter__(self):
229         try:
230             self._ssh.connect(self._node)
231         except IOError:
232             raise RuntimeError("Cannot open SSH connection to host {host} to "
233                                "execute PAPI command(s)".
234                                format(host=self._node["host"]))
235         return self
236
237     def __exit__(self, exc_type, exc_val, exc_tb):
238         self._ssh.disconnect(self._node)
239
240     def clear(self):
241         """Empty the internal command list; return self.
242
243         Use when not sure whether previous usage has left something in the list.
244
245         :returns: self, so that method chaining is possible.
246         :rtype: PapiExecutor
247         """
248         self._api_command_list = list()
249         return self
250
251     def add(self, csit_papi_command, **kwargs):
252         """Add next command to internal command list; return self.
253
254         The argument name 'csit_papi_command' must be unique enough as it cannot
255         be repeated in kwargs.
256
257         :param csit_papi_command: VPP API command.
258         :param kwargs: Optional key-value arguments.
259         :type csit_papi_command: str
260         :type kwargs: dict
261         :returns: self, so that method chaining is possible.
262         :rtype: PapiExecutor
263         """
264         PapiHistory.add_to_papi_history(self._node, csit_papi_command, **kwargs)
265         self._api_command_list.append(dict(api_name=csit_papi_command,
266                                            api_args=kwargs))
267         return self
268
269     def execute(self, process_reply=True, ignore_errors=False, timeout=120):
270         """Turn internal command list into proper data and execute; return
271         PAPI response.
272
273         This method also clears the internal command list.
274
275         :param process_reply: Process PAPI reply if True.
276         :param ignore_errors: If true, the errors in the reply are ignored.
277         :param timeout: Timeout in seconds.
278         :type process_reply: bool
279         :type ignore_errors: bool
280         :type timeout: int
281         :returns: Papi response including: papi reply, stdout, stderr and
282             return code.
283         :rtype: PapiResponse
284         :raises KeyError: If the reply is not correct.
285         """
286
287         local_list = self._api_command_list
288
289         # Clear first as execution may fail.
290         self.clear()
291
292         ret_code, stdout, stderr = self._execute_papi(local_list, timeout)
293
294         papi_reply = list()
295         if process_reply:
296             try:
297                 json_data = json.loads(stdout)
298             except ValueError:
299                 logger.error("An error occured while processing the PAPI "
300                              "request:\n{rqst}".format(rqst=local_list))
301                 raise
302             for data in json_data:
303                 try:
304                     api_reply_processed = dict(
305                         api_name=data["api_name"],
306                         api_reply=self._process_reply(data["api_reply"]))
307                 except KeyError:
308                     if ignore_errors:
309                         continue
310                     else:
311                         raise
312                 papi_reply.append(api_reply_processed)
313
314         return PapiResponse(papi_reply=papi_reply,
315                             stdout=stdout,
316                             stderr=stderr,
317                             ret_code=ret_code,
318                             requests=[rqst["api_name"] for rqst in local_list])
319
320     def execute_should_pass(self, err_msg="Failed to execute PAPI command.",
321                             process_reply=True, ignore_errors=False,
322                             timeout=120):
323         """Execute the PAPI commands and check the return code.
324         Raise exception if the PAPI command(s) failed.
325
326         :param err_msg: The message used if the PAPI command(s) execution fails.
327         :param process_reply: Indicate whether or not to process PAPI reply.
328         :param ignore_errors: If true, the errors in the reply are ignored.
329         :param timeout: Timeout in seconds.
330         :type err_msg: str
331         :type process_reply: bool
332         :type ignore_errors: bool
333         :type timeout: int
334         :returns: Papi response including: papi reply, stdout, stderr and
335             return code.
336         :rtype: PapiResponse
337         :raises AssertionError: If PAPI command(s) execution failed.
338         """
339
340         response = self.execute(process_reply=process_reply,
341                                 ignore_errors=ignore_errors,
342                                 timeout=timeout)
343
344         if response.ret_code != 0:
345             raise AssertionError(err_msg)
346         return response
347
348     def execute_should_fail(self,
349                             err_msg="Execution of PAPI command did not fail.",
350                             process_reply=False, ignore_errors=False,
351                             timeout=120):
352         """Execute the PAPI commands and check the return code.
353         Raise exception if the PAPI command(s) did not fail.
354
355         It does not return anything as we expect it fails.
356
357         :param err_msg: The message used if the PAPI command(s) execution fails.
358         :param process_reply: Indicate whether or not to process PAPI reply.
359         :param ignore_errors: If true, the errors in the reply are ignored.
360         :param timeout: Timeout in seconds.
361         :type err_msg: str
362         :type process_reply: bool
363         :type ignore_errors: bool
364         :type timeout: int
365         :raises AssertionError: If PAPI command(s) execution passed.
366         """
367
368         response = self.execute(process_reply=process_reply,
369                                 ignore_errors=ignore_errors,
370                                 timeout=timeout)
371
372         if response.ret_code == 0:
373             raise AssertionError(err_msg)
374
375     @staticmethod
376     def _process_api_data(api_d):
377         """Process API data for smooth converting to JSON string.
378
379         Apply binascii.hexlify() method for string values.
380
381         :param api_d: List of APIs with their arguments.
382         :type api_d: list
383         :returns: List of APIs with arguments pre-processed for JSON.
384         :rtype: list
385         """
386
387         api_data_processed = list()
388         for api in api_d:
389             api_args_processed = dict()
390             for a_k, a_v in api["api_args"].iteritems():
391                 value = binascii.hexlify(a_v) if isinstance(a_v, str) else a_v
392                 api_args_processed[str(a_k)] = value
393             api_data_processed.append(dict(api_name=api["api_name"],
394                                            api_args=api_args_processed))
395         return api_data_processed
396
397     @staticmethod
398     def _revert_api_reply(api_r):
399         """Process API reply / a part of API reply.
400
401         Apply binascii.unhexlify() method for unicode values.
402
403         TODO: Remove the disabled code when definitely not needed.
404
405         :param api_r: API reply.
406         :type api_r: dict
407         :returns: Processed API reply / a part of API reply.
408         :rtype: dict
409         """
410
411         reply_dict = dict()
412         reply_value = dict()
413         for reply_key, reply_v in api_r.iteritems():
414             for a_k, a_v in reply_v.iteritems():
415                 # value = binascii.unhexlify(a_v) if isinstance(a_v, unicode) \
416                 #     else a_v
417                 # reply_value[a_k] = value
418                 reply_value[a_k] = a_v
419             reply_dict[reply_key] = reply_value
420         return reply_dict
421
422     def _process_reply(self, api_reply):
423         """Process API reply.
424
425         :param api_reply: API reply.
426         :type api_reply: dict or list of dict
427         :returns: Processed API reply.
428         :rtype: list or dict
429         """
430
431         if isinstance(api_reply, list):
432             reverted_reply = [self._revert_api_reply(a_r) for a_r in api_reply]
433         else:
434             reverted_reply = self._revert_api_reply(api_reply)
435         return reverted_reply
436
437     def _execute_papi(self, api_data, timeout=120):
438         """Execute PAPI command(s) on remote node and store the result.
439
440         :param api_data: List of APIs with their arguments.
441         :param timeout: Timeout in seconds.
442         :type api_data: list
443         :type timeout: int
444         :raises SSHTimeout: If PAPI command(s) execution has timed out.
445         :raises RuntimeError: If PAPI executor failed due to another reason.
446         """
447
448         if not api_data:
449             RuntimeError("No API data provided.")
450
451         api_data_processed = self._process_api_data(api_data)
452         json_data = json.dumps(api_data_processed)
453
454         cmd = "{fw_dir}/{papi_provider} --json_data '{json}'".format(
455             fw_dir=Constants.REMOTE_FW_DIR,
456             papi_provider=Constants.RESOURCES_PAPI_PROVIDER,
457             json=json_data)
458
459         try:
460             ret_code, stdout, stderr = self._ssh.exec_command_sudo(
461                 cmd=cmd, timeout=timeout)
462         except SSHTimeout:
463             logger.error("PAPI command(s) execution timeout on host {host}:"
464                          "\n{apis}".format(host=self._node["host"],
465                                            apis=api_data))
466             raise
467         except Exception:
468             raise RuntimeError("PAPI command(s) execution on host {host} "
469                                "failed: {apis}".format(host=self._node["host"],
470                                                        apis=api_data))
471         return ret_code, stdout, stderr