b0ddccfbde8b10a3a9ab462a93a252793400387e
[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             json_data = json.loads(stdout)
297             for data in json_data:
298                 try:
299                     api_reply_processed = dict(
300                         api_name=data["api_name"],
301                         api_reply=self._process_reply(data["api_reply"]))
302                 except KeyError:
303                     if ignore_errors:
304                         continue
305                     else:
306                         raise
307                 papi_reply.append(api_reply_processed)
308
309         return PapiResponse(papi_reply=papi_reply,
310                             stdout=stdout,
311                             stderr=stderr,
312                             ret_code=ret_code,
313                             requests=[rqst["api_name"] for rqst in local_list])
314
315     def execute_should_pass(self, err_msg="Failed to execute PAPI command.",
316                             process_reply=True, ignore_errors=False,
317                             timeout=120):
318         """Execute the PAPI commands and check the return code.
319         Raise exception if the PAPI command(s) failed.
320
321         :param err_msg: The message used if the PAPI command(s) execution fails.
322         :param process_reply: Indicate whether or not to process PAPI reply.
323         :param ignore_errors: If true, the errors in the reply are ignored.
324         :param timeout: Timeout in seconds.
325         :type err_msg: str
326         :type process_reply: bool
327         :type ignore_errors: bool
328         :type timeout: int
329         :returns: Papi response including: papi reply, stdout, stderr and
330             return code.
331         :rtype: PapiResponse
332         :raises AssertionError: If PAPI command(s) execution failed.
333         """
334
335         response = self.execute(process_reply=process_reply,
336                                 ignore_errors=ignore_errors,
337                                 timeout=timeout)
338
339         if response.ret_code != 0:
340             raise AssertionError(err_msg)
341         return response
342
343     def execute_should_fail(self,
344                             err_msg="Execution of PAPI command did not fail.",
345                             process_reply=False, ignore_errors=False,
346                             timeout=120):
347         """Execute the PAPI commands and check the return code.
348         Raise exception if the PAPI command(s) did not fail.
349
350         It does not return anything as we expect it fails.
351
352         :param err_msg: The message used if the PAPI command(s) execution fails.
353         :param process_reply: Indicate whether or not to process PAPI reply.
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         :raises AssertionError: If PAPI command(s) execution passed.
361         """
362
363         response = self.execute(process_reply=process_reply,
364                                 ignore_errors=ignore_errors,
365                                 timeout=timeout)
366
367         if response.ret_code == 0:
368             raise AssertionError(err_msg)
369
370     @staticmethod
371     def _process_api_data(api_d):
372         """Process API data for smooth converting to JSON string.
373
374         Apply binascii.hexlify() method for string values.
375
376         :param api_d: List of APIs with their arguments.
377         :type api_d: list
378         :returns: List of APIs with arguments pre-processed for JSON.
379         :rtype: list
380         """
381
382         api_data_processed = list()
383         for api in api_d:
384             api_args_processed = dict()
385             for a_k, a_v in api["api_args"].iteritems():
386                 value = binascii.hexlify(a_v) if isinstance(a_v, str) else a_v
387                 api_args_processed[str(a_k)] = value
388             api_data_processed.append(dict(api_name=api["api_name"],
389                                            api_args=api_args_processed))
390         return api_data_processed
391
392     @staticmethod
393     def _revert_api_reply(api_r):
394         """Process API reply / a part of API reply.
395
396         Apply binascii.unhexlify() method for unicode values.
397
398         TODO: Remove the disabled code when definitely not needed.
399
400         :param api_r: API reply.
401         :type api_r: dict
402         :returns: Processed API reply / a part of API reply.
403         :rtype: dict
404         """
405
406         reply_dict = dict()
407         reply_value = dict()
408         for reply_key, reply_v in api_r.iteritems():
409             for a_k, a_v in reply_v.iteritems():
410                 # value = binascii.unhexlify(a_v) if isinstance(a_v, unicode) \
411                 #     else a_v
412                 # reply_value[a_k] = value
413                 reply_value[a_k] = a_v
414             reply_dict[reply_key] = reply_value
415         return reply_dict
416
417     def _process_reply(self, api_reply):
418         """Process API reply.
419
420         :param api_reply: API reply.
421         :type api_reply: dict or list of dict
422         :returns: Processed API reply.
423         :rtype: list or dict
424         """
425
426         if isinstance(api_reply, list):
427             reverted_reply = [self._revert_api_reply(a_r) for a_r in api_reply]
428         else:
429             reverted_reply = self._revert_api_reply(api_reply)
430         return reverted_reply
431
432     def _execute_papi(self, api_data, timeout=120):
433         """Execute PAPI command(s) on remote node and store the result.
434
435         :param api_data: List of APIs with their arguments.
436         :param timeout: Timeout in seconds.
437         :type api_data: list
438         :type timeout: int
439         :raises SSHTimeout: If PAPI command(s) execution has timed out.
440         :raises RuntimeError: If PAPI executor failed due to another reason.
441         """
442
443         if not api_data:
444             RuntimeError("No API data provided.")
445
446         api_data_processed = self._process_api_data(api_data)
447         json_data = json.dumps(api_data_processed)
448
449         cmd = "{fw_dir}/{papi_provider} --json_data '{json}'".format(
450             fw_dir=Constants.REMOTE_FW_DIR,
451             papi_provider=Constants.RESOURCES_PAPI_PROVIDER,
452             json=json_data)
453
454         try:
455             ret_code, stdout, stderr = self._ssh.exec_command_sudo(
456                 cmd=cmd, timeout=timeout)
457         except SSHTimeout:
458             logger.error("PAPI command(s) execution timeout on host {host}:"
459                          "\n{apis}".format(host=self._node["host"],
460                                            apis=api_data))
461             raise
462         except Exception:
463             raise RuntimeError("PAPI command(s) execution on host {host} "
464                                "failed: {apis}".format(host=self._node["host"],
465                                                        apis=api_data))
466         return ret_code, stdout, stderr