CRC checker: Sort and indent dict output
[csit.git] / resources / libraries / python / VppApiCrc.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 """Module for keeping track of VPP API CRCs relied on by CSIT."""
15
16 import json
17 import os
18 import yaml
19
20 from robot.api import logger
21
22 from resources.libraries.python.Constants import Constants
23
24 def _str(text):
25     """Convert from possible unicode without interpreting as number.
26
27     :param text: Input to convert.
28     :type text: str or unicode
29     :returns: Converted text.
30     :rtype: str
31     """
32     return text.encode("utf-8") if isinstance(text, unicode) else text
33
34
35 class VppApiCrcChecker(object):
36     """Holder of data related to tracking VPP API CRCs.
37
38     Both message names and crc hexa strings are tracked as
39     ordinary Python2 (bytes) str, so _str() is used when input is
40     possibly unicode or otherwise not safe.
41
42     Each instance of this class starts with same default state,
43     so make sure the calling libraries have appropriate robot library scope.
44     For usual testing, it means "GLOBAL" scope."""
45
46     def __init__(
47             self, directory, fail_on_mismatch=Constants.FAIL_ON_CRC_MISMATCH):
48         """Initialize empty state, then register known collections.
49
50         This also scans directory for .api.json files
51         and performs initial checks, but does not report the findings yet.
52
53         :param directory: Root directory of the search for .api.json files.
54         :type directory: str
55         """
56
57         self.fail_on_mismatch = fail_on_mismatch
58         """If True, mismatch leads to test failure, by raising exception.
59         If False, the mismatch is logged, but the test is allowed to continue.
60         """
61
62         self._expected = dict()
63         """Mapping from collection name to mapping from API name to CRC string.
64
65         Colection name should be something useful for logging.
66
67         Order of addition reflects the order colections should be queried.
68         If an incompatible CRC is found, affected collections are removed.
69         A CRC that would remove all does not, added to _reported instead,
70         while causing a failure in single test."""
71
72         self._missing = dict()
73         """Mapping from collection name to mapping from API name to CRC string.
74
75         Starts the same as _expected, but each time an encountered api,crc pair
76         fits the expectation, the pair is removed from this mapping.
77         Ideally, the active mappings will become empty.
78         If not, it is an error, VPP removed or renamed a message CSIT needs."""
79
80         self._found = dict()
81         """Mapping from API name to CRC string.
82
83         This gets populated with CRCs found in .api.json,
84         to serve as a hint when reporting errors."""
85
86         self._reported = dict()
87         """Mapping from API name to CRC string.
88
89         This gets populated with APIs used, but not found in collections,
90         just before the fact is reported in an exception.
91         The CRC comes from _found mapping (otherwise left as None).
92         The idea is to not report those next time, allowing the job
93         to find more problems in a single run."""
94
95         self._initial_conflicts_reported = False
96         self._register_all()
97         self._check_dir(directory)
98
99     def log_and_raise(self, exc_msg):
100         """Log to console, on fail_on_mismatch also raise runtime exception.
101
102         :param exc_msg: The message to include in log or exception.
103         :type exception: str
104         :raises RuntimeError: With the message, if fail_on_mismatch.
105         """
106         logger.console("RuntimeError:\n{m}".format(m=exc_msg))
107         if self.fail_on_mismatch:
108             raise RuntimeError(exc_msg)
109
110     def _register_collection(self, collection_name, name_to_crc_mapping):
111         """Add a named (copy of) collection of CRCs.
112
113         :param collection_name: Helpful string describing the collection.
114         :param name_to_crc_mapping: Mapping from API names to CRCs.
115         :type collection_name: str or unicode
116         :type name_to_crc_mapping: dict from str/unicode to str/unicode
117         """
118         collection_name = _str(collection_name)
119         if collection_name in self._expected:
120             raise RuntimeError("Collection {cl!r} already registered.".format(
121                 cl=collection_name))
122         mapping = {_str(k): _str(v) for k, v in name_to_crc_mapping.items()}
123         self._expected[collection_name] = mapping
124         self._missing[collection_name] = mapping.copy()
125
126     def _register_all(self):
127         """Add all collections this CSIT codebase is tested against."""
128
129         file_path = os.path.normpath(os.path.join(
130             os.path.dirname(os.path.abspath(__file__)), "..", "..",
131             "api", "vpp", "supported_crcs.yaml"))
132         with open(file_path, "r") as file_in:
133             collections_dict = yaml.load(file_in.read())
134         for collection_name, name_to_crc_mapping in collections_dict.items():
135             self._register_collection(collection_name, name_to_crc_mapping)
136
137     @staticmethod
138     def _get_name(msg_obj):
139         """Utility function to extract API name from an intermediate json.
140
141         :param msg_obj: Loaded json object, item of "messages" list.
142         :type msg_obj: list of various types
143         :returns: Name of the message.
144         :rtype: str or unicode
145         :raises RuntimeError: If no name is found.
146         """
147         for item in msg_obj:
148             if isinstance(item, (dict, list)):
149                 continue
150             return _str(item)
151         raise RuntimeError("No name found for message: {obj!r}".format(
152             obj=msg_obj))
153
154     @staticmethod
155     def _get_crc(msg_obj):
156         """Utility function to extract API CRC from an intermediate json.
157
158         :param msg_obj: Loaded json object, item of "messages" list.
159         :type msg_obj: list of various types
160         :returns: CRC of the message.
161         :rtype: str or unicode
162         :raises RuntimeError: If no CRC is found.
163         """
164         for item in reversed(msg_obj):
165             if not isinstance(item, dict):
166                 continue
167             crc = item.get("crc", None)
168             if crc:
169                 return _str(crc)
170         raise RuntimeError("No CRC found for message: {obj!r}".format(
171             obj=msg_obj))
172
173     def _process_crc(self, api_name, crc):
174         """Compare API to verified collections, update class state.
175
176         Conflict is NOT when a collection does not recognize the API.
177         Such APIs are merely added to _found for later reporting.
178         Conflict is when a collection recognizes the API under a different CRC.
179         If a partial match happens, only the matching collections are preserved.
180         On no match, all current collections are preserved,
181         but the offending API is added to _reported mapping.
182
183         Note that it is expected that collections are incompatible
184         with each other for some APIs. The removal of collections
185         on partial match is there to help identify the intended collection
186         for the VPP build under test. But if no collection fits perfectly,
187         the last collections to determine the "known" flag
188         depends on the order of api_name submitted,
189         which tends to be fairly random (depends on order of .api.json files).
190         Order of collection registrations does not help much in this regard.
191
192         Attempts to overwrite value in _found or _reported should not happen,
193         so the code does not check for that, simply overwriting.
194
195         The intended usage is to call this method multiple times,
196         and then raise exception listing all _reported.
197
198         :param api_name: API name to check.
199         :param crc: Discovered CRC to check for the name.
200         :type api_name: str
201         :type crc: str
202         """
203         # Regardless of the result, remember as found.
204         self._found[api_name] = crc
205         old_expected = self._expected
206         new_expected = old_expected.copy()
207         for collection_name, name_to_crc_mapping in old_expected.items():
208             if api_name not in name_to_crc_mapping:
209                 continue
210             if name_to_crc_mapping[api_name] == crc:
211                 self._missing[collection_name].pop(api_name, None)
212                 continue
213             # Remove the offending collection.
214             new_expected.pop(collection_name, None)
215         if new_expected:
216             # Some collections recognized the CRC.
217             self._expected = new_expected
218             self._missing = {name: self._missing[name] for name in new_expected}
219             return
220         # No new_expected means some colections knew the api_name,
221         # but CRC does not match any. This has to be reported.
222         self._reported[api_name] = crc
223
224     def _check_dir(self, directory):
225         """Parse every .api.json found under directory, remember conflicts.
226
227         As several collections are supported, each conflict invalidates
228         one of them, failure happens only when no collections would be left.
229         In that case, set of collections just before the failure is preserved,
230         the _reported mapping is filled with conflicting APIs.
231         The _found mapping is filled with discovered api names and crcs.
232
233         The exception is not thrown here, but from report_initial_conflicts.
234
235         :param directory: Root directory of the search for .api.json files.
236         :type directory: str
237         """
238         for root, _, files in os.walk(directory):
239             for filename in files:
240                 if not filename.endswith(".api.json"):
241                     continue
242                 with open(root + '/' + filename, "r") as file_in:
243                     json_obj = json.load(file_in)
244                 msgs = json_obj["messages"]
245                 for msg_obj in msgs:
246                     msg_name = self._get_name(msg_obj)
247                     msg_crc = self._get_crc(msg_obj)
248                     self._process_crc(msg_name, msg_crc)
249         logger.debug("Surviving collections: {col!r}".format(
250             col=self._expected.keys()))
251
252     def report_initial_conflicts(self, report_missing=False):
253         """Report issues discovered by _check_dir, if not done that already.
254
255         Intended use: Call once after init, at a time when throwing exception
256         is convenient.
257
258         Optionally, report also missing messages.
259         Missing reporting is disabled by default, because some messages
260         come from plugins that might not be enabled at runtime.
261
262         :param report_missing: Whether to raise on missing messages.
263         :type report_missing: bool
264         :raises RuntimeError: If CRC mismatch or missing messages are detected,
265             and fail_on_mismatch is True.
266         """
267         if self._initial_conflicts_reported:
268             return
269         self._initial_conflicts_reported = True
270         if self._reported:
271             reported_indented = json.dumps(
272                 self._reported, indent=1, sort_keys=True, separators=[",", ":"])
273             self.log_and_raise(
274                 "Dir check found incompatible API CRCs:\n{ri}".format(
275                     ri=reported_indented))
276         if not report_missing:
277             return
278         missing = {name: mapp for name, mapp in self._missing.items() if mapp}
279         if missing:
280             missing_indented = json.dumps(
281                 missing, indent=1, sort_keys=True, separators=[",", ":"])
282             self.log_and_raise("Dir check found missing API CRCs:\n{mi}".format(
283                 mi=missing_indented))
284
285     def check_api_name(self, api_name):
286         """Fail if the api_name has no known CRC associated.
287
288         Do not fail if this particular failure has been already reported.
289
290         Intended use: Call everytime an API call is queued or response received.
291
292         :param api_name: VPP API messagee name to check.
293         :type api_name: str or unicode
294         :raises RuntimeError: If no verified CRC for the api_name is found.
295         """
296         api_name = _str(api_name)
297         if api_name in self._reported:
298             return
299         old_expected = self._expected
300         new_expected = old_expected.copy()
301         for collection_name, name_to_crc_mapping in old_expected.items():
302             if api_name in name_to_crc_mapping:
303                 continue
304             # Remove the offending collection.
305             new_expected.pop(collection_name, None)
306         if new_expected:
307             # Some collections recognized the message name.
308             self._expected = new_expected
309             return
310         crc = self._found.get(api_name, None)
311         self._reported[api_name] = crc
312         self.log_and_raise("No active collection has API {api!r}"
313                            " CRC found {crc!r}".format(api=api_name, crc=crc))