Revert "Disable CRC checking at runtime"
[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__(self, directory,
47                  fail_on_mismatch=Constants.CRC_MISMATCH_FAILS_TEST):
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 raise_or_log(self, exception):
100         """If fail_on_mismatch, raise, else log to console the exception.
101
102         :param exception: The exception to raise or log.
103         :type exception: RuntimeError
104         """
105         if self.fail_on_mismatch:
106             raise exception
107         logger.console("{exc!r}".format(exc=exception))
108
109     def _register_collection(self, collection_name, name_to_crc_mapping):
110         """Add a named (copy of) collection of CRCs.
111
112         :param collection_name: Helpful string describing the collection.
113         :param name_to_crc_mapping: Mapping from API names to CRCs.
114         :type collection_name: str or unicode
115         :type name_to_crc_mapping: dict from str/unicode to str/unicode
116         """
117         collection_name = _str(collection_name)
118         if collection_name in self._expected:
119             raise RuntimeError("Collection {cl!r} already registered.".format(
120                 cl=collection_name))
121         mapping = {_str(k): _str(v) for k, v in name_to_crc_mapping.items()}
122         self._expected[collection_name] = mapping
123         self._missing[collection_name] = mapping.copy()
124
125     def _register_all(self):
126         """Add all collections this CSIT codebase is tested against."""
127
128         file_path = os.path.normpath(os.path.join(
129             os.path.dirname(os.path.abspath(__file__)), "..", "..",
130             "api", "vpp", "supported_crcs.yaml"))
131         with open(file_path, "r") as file_in:
132             collections_dict = yaml.load(file_in.read())
133         for collection_name, name_to_crc_mapping in collections_dict.items():
134             self._register_collection(collection_name, name_to_crc_mapping)
135
136     @staticmethod
137     def _get_name(msg_obj):
138         """Utility function to extract API name from an intermediate json.
139
140         :param msg_obj: Loaded json object, item of "messages" list.
141         :type msg_obj: list of various types
142         :returns: Name of the message.
143         :rtype: str or unicode
144         :raises RuntimeError: If no name is found.
145         """
146         for item in msg_obj:
147             if isinstance(item, (dict, list)):
148                 continue
149             return _str(item)
150         raise RuntimeError("No name found for message: {obj!r}".format(
151             obj=msg_obj))
152
153     @staticmethod
154     def _get_crc(msg_obj):
155         """Utility function to extract API CRC from an intermediate json.
156
157         :param msg_obj: Loaded json object, item of "messages" list.
158         :type msg_obj: list of various types
159         :returns: CRC of the message.
160         :rtype: str or unicode
161         :raises RuntimeError: If no CRC is found.
162         """
163         for item in reversed(msg_obj):
164             if not isinstance(item, dict):
165                 continue
166             crc = item.get("crc", None)
167             if crc:
168                 return _str(crc)
169         raise RuntimeError("No CRC found for message: {obj!r}".format(
170             obj=msg_obj))
171
172     def _process_crc(self, api_name, crc):
173         """Compare API to verified collections, update class state.
174
175         Conflict is NOT when a collection does not recognize the API.
176         Such APIs are merely added to _found for later reporting.
177         Conflict is when a collection recognizes the API under a different CRC.
178         If a partial match happens, only the matching collections are preserved.
179         On no match, all current collections are preserved,
180         but the offending API is added to _reported mapping.
181
182         Note that it is expected that collections are incompatible
183         with each other for some APIs. The removal of collections
184         on partial match is there to help identify the intended collection
185         for the VPP build under test. But if no collection fits perfectly,
186         the last collections to determine the "known" flag
187         depends on the order of api_name submitted,
188         which tends to be fairly random (depends on order of .api.json files).
189         Order of collection registrations does not help much in this regard.
190
191         Attempts to overwrite value in _found or _reported should not happen,
192         so the code does not check for that, simply overwriting.
193
194         The intended usage is to call this method multiple times,
195         and then raise exception listing all _reported.
196
197         :param api_name: API name to check.
198         :param crc: Discovered CRC to check for the name.
199         :type api_name: str
200         :type crc: str
201         """
202         # Regardless of the result, remember as found.
203         self._found[api_name] = crc
204         old_expected = self._expected
205         new_expected = old_expected.copy()
206         for collection_name, name_to_crc_mapping in old_expected.items():
207             if api_name not in name_to_crc_mapping:
208                 continue
209             if name_to_crc_mapping[api_name] == crc:
210                 self._missing[collection_name].pop(api_name, None)
211                 continue
212             # Remove the offending collection.
213             new_expected.pop(collection_name, None)
214         if new_expected:
215             # Some collections recognized the CRC.
216             self._expected = new_expected
217             self._missing = {name: self._missing[name] for name in new_expected}
218             return
219         # No new_expected means some colections knew the api_name,
220         # but CRC does not match any. This has to be reported.
221         self._reported[api_name] = crc
222
223     def _check_dir(self, directory):
224         """Parse every .api.json found under directory, remember conflicts.
225
226         As several collections are supported, each conflict invalidates
227         one of them, failure happens only when no collections would be left.
228         In that case, set of collections just before the failure is preserved,
229         the _reported mapping is filled with conflicting APIs.
230         The _found mapping is filled with discovered api names and crcs.
231
232         The exception is not thrown here, but from report_initial_conflicts.
233
234         :param directory: Root directory of the search for .api.json files.
235         :type directory: str
236         """
237         for root, _, files in os.walk(directory):
238             for filename in files:
239                 if not filename.endswith(".api.json"):
240                     continue
241                 with open(root + '/' + filename, "r") as file_in:
242                     json_obj = json.load(file_in)
243                 msgs = json_obj["messages"]
244                 for msg_obj in msgs:
245                     msg_name = self._get_name(msg_obj)
246                     msg_crc = self._get_crc(msg_obj)
247                     self._process_crc(msg_name, msg_crc)
248         logger.debug("Surviving collections: {col!r}".format(
249             col=self._expected.keys()))
250
251     def report_initial_conflicts(self, report_missing=False):
252         """Report issues discovered by _check_dir, if not done that already.
253
254         Intended use: Call once after init, at a time when throwing exception
255         is convenient.
256
257         Optionally, report also missing messages.
258         Missing reporting is disabled by default, because some messages
259         come from plugins that might not be enabled at runtime.
260
261         :param report_missing: Whether to raise on missing messages.
262         :type report_missing: bool
263         :raises RuntimeError: If CRC mismatch or missing messages are detected,
264             and fail_on_mismatch is True.
265         """
266         if self._initial_conflicts_reported:
267             return
268         self._initial_conflicts_reported = True
269         if self._reported:
270             self.raise_or_log(
271                 RuntimeError("Dir check found incompatible API CRCs: {rep!r}"\
272                     .format(rep=self._reported)))
273         if not report_missing:
274             return
275         missing = {name: mapp for name, mapp in self._missing.items() if mapp}
276         if missing:
277             self.raise_or_log(
278                 RuntimeError("Dir check found missing API CRCs: {mis!r}"\
279                     .format(mis=missing)))
280
281     def check_api_name(self, api_name):
282         """Fail if the api_name has no known CRC associated.
283
284         Do not fail if this particular failure has been already reported.
285
286         Intended use: Call everytime an API call is queued or response received.
287
288         :param api_name: VPP API messagee name to check.
289         :type api_name: str or unicode
290         :raises RuntimeError: If no verified CRC for the api_name is found.
291         """
292         api_name = _str(api_name)
293         if api_name in self._reported:
294             return
295         old_expected = self._expected
296         new_expected = old_expected.copy()
297         for collection_name, name_to_crc_mapping in old_expected.items():
298             if api_name in name_to_crc_mapping:
299                 continue
300             # Remove the offending collection.
301             new_expected.pop(collection_name, None)
302         if new_expected:
303             # Some collections recognized the message name.
304             self._expected = new_expected
305             return
306         crc = self._found.get(api_name, None)
307         self._reported[api_name] = crc
308         self.raise_or_log(
309             RuntimeError("No active collection has API {api!r}"
310                          " CRC found {crc!r}".format(api=api_name, crc=crc)))