PapiExecutor always verifies
[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 pprint import pformat
21 from robot.api import logger
22
23 from resources.libraries.python.Constants import Constants
24 from resources.libraries.python.PapiHistory import PapiHistory
25 from resources.libraries.python.PythonThree import raise_from
26 from resources.libraries.python.ssh import SSH, SSHTimeout
27
28
29 __all__ = ["PapiExecutor"]
30
31
32 class PapiExecutor(object):
33     """Contains methods for executing VPP Python API commands on DUTs.
34
35     Note: Use only with "with" statement, e.g.:
36
37         with PapiExecutor(node) as papi_exec:
38             replies = papi_exec.add('show_version').get_replies(err_msg)
39
40     This class processes three classes of VPP PAPI methods:
41     1. simple request / reply: method='request',
42     2. dump functions: method='dump',
43     3. vpp-stats: method='stats'.
44
45     The recommended ways of use are (examples):
46
47     1. Simple request / reply
48
49     a. One request with no arguments:
50
51         with PapiExecutor(node) as papi_exec:
52             reply = papi_exec.add('show_version').get_reply()
53
54     b. Three requests with arguments, the second and the third ones are the same
55        but with different arguments.
56
57         with PapiExecutor(node) as papi_exec:
58             replies = papi_exec.add(cmd1, **args1).add(cmd2, **args2).\
59                 add(cmd2, **args3).get_replies(err_msg)
60
61     2. Dump functions
62
63         cmd = 'sw_interface_rx_placement_dump'
64         with PapiExecutor(node) as papi_exec:
65             details = papi_exec.add(cmd, sw_if_index=ifc['vpp_sw_index']).\
66                 get_details(err_msg)
67
68     3. vpp-stats
69
70         path = ['^/if', '/err/ip4-input', '/sys/node/ip4-input']
71
72         with PapiExecutor(node) as papi_exec:
73             stats = papi_exec.add(api_name='vpp-stats', path=path).get_stats()
74
75         print('RX interface core 0, sw_if_index 0:\n{0}'.\
76             format(stats[0]['/if/rx'][0][0]))
77
78         or
79
80         path_1 = ['^/if', ]
81         path_2 = ['^/if', '/err/ip4-input', '/sys/node/ip4-input']
82
83         with PapiExecutor(node) as papi_exec:
84             stats = papi_exec.add('vpp-stats', path=path_1).\
85                 add('vpp-stats', path=path_2).get_stats()
86
87         print('RX interface core 0, sw_if_index 0:\n{0}'.\
88             format(stats[1]['/if/rx'][0][0]))
89
90         Note: In this case, when PapiExecutor method 'add' is used:
91         - its parameter 'csit_papi_command' is used only to keep information
92           that vpp-stats are requested. It is not further processed but it is
93           included in the PAPI history this way:
94           vpp-stats(path=['^/if', '/err/ip4-input', '/sys/node/ip4-input'])
95           Always use csit_papi_command="vpp-stats" if the VPP PAPI method
96           is "stats".
97         - the second parameter must be 'path' as it is used by PapiExecutor
98           method 'add'.
99     """
100
101     def __init__(self, node):
102         """Initialization.
103
104         :param node: Node to run command(s) on.
105         :type node: dict
106         """
107
108         # Node to run command(s) on.
109         self._node = node
110
111         # The list of PAPI commands to be executed on the node.
112         self._api_command_list = list()
113
114         self._ssh = SSH()
115
116     def __enter__(self):
117         try:
118             self._ssh.connect(self._node)
119         except IOError:
120             raise RuntimeError("Cannot open SSH connection to host {host} to "
121                                "execute PAPI command(s)".
122                                format(host=self._node["host"]))
123         return self
124
125     def __exit__(self, exc_type, exc_val, exc_tb):
126         self._ssh.disconnect(self._node)
127
128     def add(self, csit_papi_command="vpp-stats", history=True, **kwargs):
129         """Add next command to internal command list; return self.
130
131         The argument name 'csit_papi_command' must be unique enough as it cannot
132         be repeated in kwargs.
133
134         :param csit_papi_command: VPP API command.
135         :param history: Enable/disable adding command to PAPI command history.
136         :param kwargs: Optional key-value arguments.
137         :type csit_papi_command: str
138         :type history: bool
139         :type kwargs: dict
140         :returns: self, so that method chaining is possible.
141         :rtype: PapiExecutor
142         """
143         if history:
144             PapiHistory.add_to_papi_history(
145                 self._node, csit_papi_command, **kwargs)
146         self._api_command_list.append(dict(api_name=csit_papi_command,
147                                            api_args=kwargs))
148         return self
149
150     def get_stats(self, err_msg="Failed to get statistics.", timeout=120):
151         """Get VPP Stats from VPP Python API.
152
153         :param err_msg: The message used if the PAPI command(s) execution fails.
154         :param timeout: Timeout in seconds.
155         :type err_msg: str
156         :type timeout: int
157         :returns: Requested VPP statistics.
158         :rtype: list of dict
159         """
160
161         paths = [cmd['api_args']['path'] for cmd in self._api_command_list]
162         self._api_command_list = list()
163
164         stdout = self._execute_papi(
165             paths, method='stats', err_msg=err_msg, timeout=timeout)
166
167         return json.loads(stdout)
168
169     def get_replies(self, err_msg="Failed to get replies.", timeout=120):
170         """Get replies from VPP Python API.
171
172         The replies are parsed into dict-like objects,
173         "retval" field is guaranteed to be zero on success.
174
175         :param err_msg: The message used if the PAPI command(s) execution fails.
176         :param timeout: Timeout in seconds.
177         :type err_msg: str
178         :type timeout: int
179         :returns: Responses, dict objects with fields due to API and "retval".
180         :rtype: list of dict
181         :raises RuntimeError: If retval is nonzero, parsing or ssh error.
182         """
183         return self._execute(method='request', err_msg=err_msg, timeout=timeout)
184
185     def get_reply(self, err_msg="Failed to get reply.", timeout=120):
186         """Get reply from VPP Python API.
187
188         The reply is parsed into dict-like object,
189         "retval" field is guaranteed to be zero on success.
190
191         TODO: Discuss exception types to raise, unify with inner methods.
192
193         :param err_msg: The message used if the PAPI command(s) execution fails.
194         :param timeout: Timeout in seconds.
195         :type err_msg: str
196         :type timeout: int
197         :returns: Response, dict object with fields due to API and "retval".
198         :rtype: dict
199         :raises AssertionError: If retval is nonzero, parsing or ssh error.
200         """
201         replies = self.get_replies(err_msg=err_msg, timeout=timeout)
202         if len(replies) != 1:
203             raise RuntimeError("Expected single reply, got {replies!r}".format(
204                 replies=replies))
205         return replies[0]
206
207     def get_sw_if_index(self, err_msg="Failed to get reply.", timeout=120):
208         """Get sw_if_index from reply from VPP Python API.
209
210         Frequently, the caller is only interested in sw_if_index field
211         of the reply, this wrapper makes such call sites shorter.
212
213         TODO: Discuss exception types to raise, unify with inner methods.
214
215         :param err_msg: The message used if the PAPI command(s) execution fails.
216         :param timeout: Timeout in seconds.
217         :type err_msg: str
218         :type timeout: int
219         :returns: Response, sw_if_index value of the reply.
220         :rtype: int
221         :raises AssertionError: If retval is nonzero, parsing or ssh error.
222         """
223         return self.get_reply(err_msg=err_msg, timeout=timeout)["sw_if_index"]
224
225     def get_details(self, err_msg="Failed to get dump details.", timeout=120):
226         """Get dump details from VPP Python API.
227
228         The details are parsed into dict-like objects.
229         The number of details per single dump command can vary,
230         and all association between details and dumps is lost,
231         so if you care about the association (as opposed to
232         logging everything at once for debugging purposes),
233         it is recommended to call get_details for each dump (type) separately.
234
235         :param err_msg: The message used if the PAPI command(s) execution fails.
236         :param timeout: Timeout in seconds.
237         :type err_msg: str
238         :type timeout: int
239         :returns: Details, dict objects with fields due to API without "retval".
240         :rtype: list of dict
241         """
242         return self._execute(method='dump', err_msg=err_msg, timeout=timeout)
243
244     @staticmethod
245     def dump_and_log(node, cmds):
246         """Dump and log requested information, return None.
247
248         :param node: DUT node.
249         :param cmds: Dump commands to be executed.
250         :type node: dict
251         :type cmds: list of str
252         """
253         with PapiExecutor(node) as papi_exec:
254             for cmd in cmds:
255                 details = papi_exec.add(cmd).get_details()
256                 logger.debug("{cmd}:\n{details}".format(
257                     cmd=cmd, details=pformat(details)))
258
259     @staticmethod
260     def run_cli_cmd(node, cmd, log=True):
261         """Run a CLI command as cli_inband, return the "reply" field of reply.
262
263         Optionally, log the field value.
264
265         :param node: Node to run command on.
266         :param cmd: The CLI command to be run on the node.
267         :param log: If True, the response is logged.
268         :type node: dict
269         :type cmd: str
270         :type log: bool
271         :returns: CLI output.
272         :rtype: str
273         """
274
275         cli = 'cli_inband'
276         args = dict(cmd=cmd)
277         err_msg = "Failed to run 'cli_inband {cmd}' PAPI command on host " \
278                   "{host}".format(host=node['host'], cmd=cmd)
279
280         with PapiExecutor(node) as papi_exec:
281             reply = papi_exec.add(cli, **args).get_reply(err_msg)["reply"]
282
283         if log:
284             logger.info("{cmd}:\n{reply}".format(cmd=cmd, reply=reply))
285
286         return reply
287
288     @staticmethod
289     def _process_api_data(api_d):
290         """Process API data for smooth converting to JSON string.
291
292         Apply binascii.hexlify() method for string values.
293
294         :param api_d: List of APIs with their arguments.
295         :type api_d: list
296         :returns: List of APIs with arguments pre-processed for JSON.
297         :rtype: list
298         """
299
300         def process_value(val):
301             """Process value.
302
303             :param val: Value to be processed.
304             :type val: object
305             :returns: Processed value.
306             :rtype: dict or str or int
307             """
308             if isinstance(val, dict):
309                 for val_k, val_v in val.iteritems():
310                     val[str(val_k)] = process_value(val_v)
311                 return val
312             elif isinstance(val, list):
313                 for idx, val_l in enumerate(val):
314                     val[idx] = process_value(val_l)
315                 return val
316             else:
317                 return binascii.hexlify(val) if isinstance(val, str) else val
318
319         api_data_processed = list()
320         for api in api_d:
321             api_args_processed = dict()
322             for a_k, a_v in api["api_args"].iteritems():
323                 api_args_processed[str(a_k)] = process_value(a_v)
324             api_data_processed.append(dict(api_name=api["api_name"],
325                                            api_args=api_args_processed))
326         return api_data_processed
327
328     @staticmethod
329     def _revert_api_reply(api_r):
330         """Process API reply / a part of API reply.
331
332         Apply binascii.unhexlify() method for unicode values.
333
334         TODO: Implement complex solution to process of replies.
335
336         :param api_r: API reply.
337         :type api_r: dict
338         :returns: Processed API reply / a part of API reply.
339         :rtype: dict
340         """
341         def process_value(val):
342             """Process value.
343
344             :param val: Value to be processed.
345             :type val: object
346             :returns: Processed value.
347             :rtype: dict or str or int
348             """
349             if isinstance(val, dict):
350                 for val_k, val_v in val.iteritems():
351                     val[str(val_k)] = process_value(val_v)
352                 return val
353             elif isinstance(val, list):
354                 for idx, val_l in enumerate(val):
355                     val[idx] = process_value(val_l)
356                 return val
357             elif isinstance(val, unicode):
358                 return binascii.unhexlify(val)
359             else:
360                 return val
361
362         reply_dict = dict()
363         reply_value = dict()
364         for reply_key, reply_v in api_r.iteritems():
365             for a_k, a_v in reply_v.iteritems():
366                 reply_value[a_k] = process_value(a_v)
367             reply_dict[reply_key] = reply_value
368         return reply_dict
369
370     def _process_reply(self, api_reply):
371         """Process API reply.
372
373         :param api_reply: API reply.
374         :type api_reply: dict or list of dict
375         :returns: Processed API reply.
376         :rtype: list or dict
377         """
378         if isinstance(api_reply, list):
379             reverted_reply = [self._revert_api_reply(a_r) for a_r in api_reply]
380         else:
381             reverted_reply = self._revert_api_reply(api_reply)
382         return reverted_reply
383
384     def _execute_papi(self, api_data, method='request', err_msg="",
385                       timeout=120):
386         """Execute PAPI command(s) on remote node and store the result.
387
388         :param api_data: List of APIs with their arguments.
389         :param method: VPP Python API method. Supported methods are: 'request',
390             'dump' and 'stats'.
391         :param err_msg: The message used if the PAPI command(s) execution fails.
392         :param timeout: Timeout in seconds.
393         :type api_data: list
394         :type method: str
395         :type err_msg: str
396         :type timeout: int
397         :returns: Stdout from remote python utility, to be parsed by caller.
398         :rtype: str
399         :raises SSHTimeout: If PAPI command(s) execution has timed out.
400         :raises RuntimeError: If PAPI executor failed due to another reason.
401         :raises AssertionError: If PAPI command(s) execution has failed.
402         """
403
404         if not api_data:
405             raise RuntimeError("No API data provided.")
406
407         json_data = json.dumps(api_data) \
408             if method in ("stats", "stats_request") \
409             else json.dumps(self._process_api_data(api_data))
410
411         cmd = "{fw_dir}/{papi_provider} --method {method} --data '{json}'".\
412             format(
413                 fw_dir=Constants.REMOTE_FW_DIR, method=method, json=json_data,
414                 papi_provider=Constants.RESOURCES_PAPI_PROVIDER)
415         try:
416             ret_code, stdout, _ = self._ssh.exec_command_sudo(
417                 cmd=cmd, timeout=timeout, log_stdout_err=False)
418         # TODO: Fail on non-empty stderr?
419         except SSHTimeout:
420             logger.error("PAPI command(s) execution timeout on host {host}:"
421                          "\n{apis}".format(host=self._node["host"],
422                                            apis=api_data))
423             raise
424         except Exception as exc:
425             raise_from(RuntimeError(
426                 "PAPI command(s) execution on host {host} "
427                 "failed: {apis}".format(
428                     host=self._node["host"], apis=api_data)), exc)
429         if ret_code != 0:
430             raise AssertionError(err_msg)
431
432         return stdout
433
434     def _execute(self, method='request', err_msg="", timeout=120):
435         """Turn internal command list into data and execute; return replies.
436
437         This method also clears the internal command list.
438
439         IMPORTANT!
440         Do not use this method in L1 keywords. Use:
441         - get_stats()
442         - get_replies()
443         - get_details()
444
445         :param method: VPP Python API method. Supported methods are: 'request',
446             'dump' and 'stats'.
447         :param err_msg: The message used if the PAPI command(s) execution fails.
448         :param timeout: Timeout in seconds.
449         :type method: str
450         :type err_msg: str
451         :type timeout: int
452         :returns: Papi responses parsed into a dict-like object,
453             with field due to API or stats hierarchy.
454         :rtype: list of dict
455         :raises KeyError: If the reply is not correct.
456         """
457
458         local_list = self._api_command_list
459
460         # Clear first as execution may fail.
461         self._api_command_list = list()
462
463         stdout = self._execute_papi(
464             local_list, method=method, err_msg=err_msg, timeout=timeout)
465         replies = list()
466         try:
467             json_data = json.loads(stdout)
468         except ValueError as err:
469             raise_from(RuntimeError(err_msg), err)
470         for data in json_data:
471             if method == "request":
472                 api_reply = self._process_reply(data["api_reply"])
473                 # api_reply contains single key, *_reply.
474                 obj = api_reply.values()[0]
475                 retval = obj["retval"]
476                 if retval != 0:
477                     # TODO: What exactly to log and raise here?
478                     err = AssertionError("Got retval {rv!r}".format(rv=retval))
479                     raise_from(AssertionError(err_msg), err, level="INFO")
480                 replies.append(obj)
481             elif method == "dump":
482                 api_reply = self._process_reply(data["api_reply"])
483                 # api_reply is a list where item contas single key, *_details.
484                 for item in api_reply:
485                     obj = item.values()[0]
486                     replies.append(obj)
487             else:
488                 # TODO: Implement support for stats.
489                 raise RuntimeError("Unsuported method {method}".format(
490                     method=method))
491
492         # TODO: Make logging optional?
493         logger.debug("PAPI replies: {replies}".format(replies=replies))
494
495         return replies