CSIT-1450: PAPI executor
[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 import binascii
17 import json
18
19 from robot.api import logger
20
21 from resources.libraries.python.Constants import Constants
22 from resources.libraries.python.ssh import SSH, SSHTimeout
23
24 __all__ = ["PapiExecutor", "PapiResponse"]
25
26 # TODO: Implement Papi History
27 # from resources.libraries.python.PapiHistory import PapiHistory
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         """Construct the Papi response by setting the values needed.
37
38         :param papi_reply: API reply from last executed PAPI command(s).
39         :param stdout: stdout from last executed PAPI command(s).
40         :param stderr: stderr from last executed PAPI command(s).
41         :param ret_code: ret_code from last executed PAPI command(s).
42         :type papi_reply: list
43         :type stdout: str
44         :type stderr: str
45         :type ret_code: int
46         """
47
48         # API reply from last executed PAPI command(s)
49         self.reply = papi_reply
50
51         # stdout from last executed PAPI command(s)
52         self.stdout = stdout
53
54         # stderr from last executed PAPI command(s).
55         self.stderr = stderr
56
57         # return code from last executed PAPI command(s)
58         self.ret_code = ret_code
59
60     def __str__(self):
61         """Return string with human readable description of the group.
62
63         :returns: Readable description.
64         :rtype: str
65         """
66         return ("papi_reply={papi_reply} "
67                 "stdout={stdout} "
68                 "stderr={stderr} "
69                 "ret_code={ret_code}".
70                 format(papi_reply=self.reply,
71                        stdout=self.stdout,
72                        stderr=self.stderr,
73                        ret_code=self.ret_code))
74
75     def __repr__(self):
76         """Return string executable as Python constructor call.
77
78         :returns: Executable constructor call.
79         :rtype: str
80         """
81         return ("PapiResponse(papi_reply={papi_reply} "
82                 "stdout={stdout} "
83                 "stderr={stderr} "
84                 "ret_code={ret_code})".
85                 format(papi_reply=self.reply,
86                        stdout=self.stdout,
87                        stderr=self.stderr,
88                        ret_code=self.ret_code))
89
90
91 class PapiExecutor(object):
92     """Contains methods for executing Python API commands on DUTs.
93
94     Use only with "with" statement, e.g.:
95
96     with PapiExecutor(node) as papi_exec:
97         papi_resp = papi_exec.add('show_version').execute_should_pass(err_msg)
98     """
99
100     def __init__(self, node):
101         """Initialization.
102
103         :param node: Node to run command(s) on.
104         :type node: dict
105         """
106
107         # Node to run command(s) on.
108         self._node = node
109
110         # The list of PAPI commands to be executed on the node.
111         self._api_command_list = list()
112
113         # The response on the PAPI commands.
114         self.response = PapiResponse()
115
116         self._ssh = SSH()
117
118     def __enter__(self):
119         try:
120             self._ssh.connect(self._node)
121         except IOError:
122             raise RuntimeError("Cannot open SSH connection to host {host} to "
123                                "execute PAPI command(s)".
124                                format(host=self._node["host"]))
125         return self
126
127     def __exit__(self, exc_type, exc_val, exc_tb):
128         self._ssh.disconnect(self._node)
129
130     def clear(self):
131         """Empty the internal command list; return self.
132
133         Use when not sure whether previous usage has left something in the list.
134
135         :returns: self, so that method chaining is possible.
136         :rtype: PapiExecutor
137         """
138         self._api_command_list = list()
139         return self
140
141     def add(self, command, **kwargs):
142         """Add next command to internal command list; return self.
143
144         :param command: VPP API command.
145         :param kwargs: Optional key-value arguments.
146         :type command: str
147         :type kwargs: dict
148         :returns: self, so that method chaining is possible.
149         :rtype: PapiExecutor
150         """
151         self._api_command_list.append(dict(api_name=command, api_args=kwargs))
152         return self
153
154     def execute(self, process_reply=True, ignore_errors=False, timeout=120):
155         """Turn internal command list into proper data and execute; return
156         PAPI response.
157
158         This method also clears the internal command list.
159
160         :param process_reply: Process PAPI reply if True.
161         :param ignore_errors: If true, the errors in the reply are ignored.
162         :param timeout: Timeout in seconds.
163         :type process_reply: bool
164         :type ignore_errors: bool
165         :type timeout: int
166         :returns: Papi response including: papi reply, stdout, stderr and
167             return code.
168         :rtype: PapiResponse
169         :raises KeyError: If the reply is not correct.
170         """
171
172         local_list = self._api_command_list
173
174         # Clear first as execution may fail.
175         self.clear()
176
177         ret_code, stdout, stderr = self._execute_papi(local_list, timeout)
178
179         papi_reply = list()
180         if process_reply:
181             json_data = json.loads(stdout)
182             for data in json_data:
183                 try:
184                     api_reply_processed = dict(
185                         api_name=data["api_name"],
186                         api_reply=self._process_reply(data["api_reply"]))
187                 except KeyError:
188                     if ignore_errors:
189                         continue
190                     else:
191                         raise
192                 papi_reply.append(api_reply_processed)
193
194         return PapiResponse(papi_reply=papi_reply,
195                             stdout=stdout,
196                             stderr=stderr,
197                             ret_code=ret_code)
198
199     def execute_should_pass(self, err_msg="Failed to execute PAPI command.",
200                             process_reply=True, ignore_errors=False,
201                             timeout=120):
202         """Execute the PAPI commands and check the return code.
203         Raise exception if the PAPI command(s) failed.
204
205         Note: There are two exceptions raised to distinguish two situations. If
206         not needed, re-implement using only RuntimeError.
207
208         :param err_msg: The message used if the PAPI command(s) execution fails.
209         :param process_reply: Indicate whether or not to process PAPI reply.
210         :param ignore_errors: If true, the errors in the reply are ignored.
211         :param timeout: Timeout in seconds.
212         :type err_msg: str
213         :type process_reply: bool
214         :type ignore_errors: bool
215         :type timeout: int
216         :returns: Papi response including: papi reply, stdout, stderr and
217             return code.
218         :rtype: PapiResponse
219         :raises RuntimeError: If no PAPI command(s) executed.
220         :raises AssertionError: If PAPI command(s) execution passed.
221         """
222
223         response = self.execute(process_reply=process_reply,
224                                 ignore_errors=ignore_errors,
225                                 timeout=timeout)
226
227         if response.ret_code != 0:
228             raise AssertionError(err_msg)
229         return response
230
231     def execute_should_fail(self,
232                             err_msg="Execution of PAPI command did not fail.",
233                             process_reply=False, ignore_errors=False,
234                             timeout=120):
235         """Execute the PAPI commands and check the return code.
236         Raise exception if the PAPI command(s) did not fail.
237
238         It does not return anything as we expect it fails.
239
240         Note: There are two exceptions raised to distinguish two situations. If
241         not needed, re-implement using only RuntimeError.
242
243         :param err_msg: The message used if the PAPI command(s) execution fails.
244         :param process_reply: Indicate whether or not to process PAPI reply.
245         :param ignore_errors: If true, the errors in the reply are ignored.
246         :param timeout: Timeout in seconds.
247         :type err_msg: str
248         :type process_reply: bool
249         :type ignore_errors: bool
250         :type timeout: int
251         :raises RuntimeError: If no PAPI command(s) executed.
252         :raises AssertionError: If PAPI command(s) execution passed.
253         """
254
255         response = self.execute(process_reply=process_reply,
256                                 ignore_errors=ignore_errors,
257                                 timeout=timeout)
258
259         if response.ret_code == 0:
260             raise AssertionError(err_msg)
261
262     @staticmethod
263     def _process_api_data(api_d):
264         """Process API data for smooth converting to JSON string.
265
266         Apply binascii.hexlify() method for string values.
267
268         :param api_d: List of APIs with their arguments.
269         :type api_d: list
270         :returns: List of APIs with arguments pre-processed for JSON.
271         :rtype: list
272         """
273
274         api_data_processed = list()
275         for api in api_d:
276             api_args_processed = dict()
277             for a_k, a_v in api["api_args"].iteritems():
278                 value = binascii.hexlify(a_v) if isinstance(a_v, str) else a_v
279                 api_args_processed[str(a_k)] = value
280             api_data_processed.append(dict(api_name=api["api_name"],
281                                            api_args=api_args_processed))
282         return api_data_processed
283
284     @staticmethod
285     def _revert_api_reply(api_r):
286         """Process API reply / a part of API reply.
287
288         Apply binascii.unhexlify() method for unicode values.
289
290         TODO: Remove the disabled code when definitely not needed.
291
292         :param api_r: API reply.
293         :type api_r: dict
294         :returns: Processed API reply / a part of API reply.
295         :rtype: dict
296         """
297
298         reply_dict = dict()
299         reply_value = dict()
300         for reply_key, reply_v in api_r.iteritems():
301             for a_k, a_v in reply_v.iteritems():
302                 # value = binascii.unhexlify(a_v) if isinstance(a_v, unicode) \
303                 #     else a_v
304                 # reply_value[a_k] = value
305                 reply_value[a_k] = a_v
306             reply_dict[reply_key] = reply_value
307         return reply_dict
308
309     def _process_reply(self, api_reply):
310         """Process API reply.
311
312         :param api_reply: API reply.
313         :type api_reply: dict or list of dict
314         :returns: Processed API reply.
315         :rtype: list or dict
316         """
317
318         if isinstance(api_reply, list):
319             reverted_reply = [self._revert_api_reply(a_r) for a_r in api_reply]
320         else:
321             reverted_reply = self._revert_api_reply(api_reply)
322         return reverted_reply
323
324     def _execute_papi(self, api_data, timeout=120):
325         """Execute PAPI command(s) on remote node and store the result.
326
327         :param api_data: List of APIs with their arguments.
328         :param timeout: Timeout in seconds.
329         :type api_data: list
330         :type timeout: int
331         :raises SSHTimeout: If PAPI command(s) execution has timed out.
332         :raises RuntimeError: If PAPI executor failed due to another reason.
333         """
334
335         if not api_data:
336             RuntimeError("No API data provided.")
337
338         api_data_processed = self._process_api_data(api_data)
339         json_data = json.dumps(api_data_processed)
340
341         cmd = "python {fw_dir}/{papi_provider} --json_data '{json}'".format(
342             fw_dir=Constants.REMOTE_FW_DIR,
343             papi_provider=Constants.RESOURCES_PAPI_PROVIDER,
344             json=json_data)
345
346         try:
347             ret_code, stdout, stderr = self._ssh.exec_command_sudo(
348                 cmd=cmd, timeout=timeout)
349         except SSHTimeout:
350             logger.error("PAPI command(s) execution timeout on host {host}:"
351                          "\n{apis}".format(host=self._node["host"],
352                                            apis=api_data))
353             raise
354         except Exception:
355             raise RuntimeError("PAPI command(s) execution on host {host} "
356                                "failed: {apis}".format(host=self._node["host"],
357                                                        apis=api_data))
358         return ret_code, stdout, stderr