feat(jumpavg): support small values via unit param
[csit.git] / resources / libraries / python / model / ExportJson.py
1 # Copyright (c) 2023 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 tracking json in-memory data and saving it to files.
15
16 Each test case, suite setup (hierarchical) and teardown has its own file pair.
17
18 Validation is performed for output files with available JSON schema.
19 Validation is performed in data deserialized from disk,
20 as serialization might have introduced subtle errors.
21 """
22
23 import datetime
24 import os.path
25
26 from binascii import b2a_base64
27 from dateutil.parser import parse
28 from robot.api import logger
29 from robot.libraries.BuiltIn import BuiltIn
30 from zlib import compress
31
32 from resources.libraries.python.Constants import Constants
33 from resources.libraries.python.jumpavg import AvgStdevStats
34 from resources.libraries.python.model.ExportResult import (
35     export_dut_type_and_version, export_tg_type_and_version
36 )
37 from resources.libraries.python.model.MemDump import write_output
38 from resources.libraries.python.model.validate import (
39     get_validators, validate
40 )
41
42
43 class ExportJson():
44     """Class handling the json data setting and export."""
45
46     ROBOT_LIBRARY_SCOPE = u"GLOBAL"
47
48     def __init__(self):
49         """Declare required fields, cache output dir.
50
51         Also memorize schema validator instances.
52         """
53         self.output_dir = BuiltIn().get_variable_value(u"\\${OUTPUT_DIR}", ".")
54         self.file_path = None
55         self.data = None
56         self.validators = get_validators()
57
58     def _detect_test_type(self):
59         """Return test_type, as inferred from robot test tags.
60
61         :returns: The inferred test type value.
62         :rtype: str
63         :raises RuntimeError: If the test tags does not contain expected values.
64         """
65         tags = self.data[u"tags"]
66         # First 5 options are specific for VPP tests.
67         if u"DEVICETEST" in tags:
68             test_type = u"device"
69         elif u"LDP_NGINX" in tags:
70             test_type = u"hoststack"
71         elif u"HOSTSTACK" in tags:
72             test_type = u"hoststack"
73         elif u"GSO_TRUE" in tags or u"GSO_FALSE" in tags:
74             test_type = u"gso"
75         elif u"RECONF" in tags:
76             test_type = u"reconf"
77         # The remaining 3 options could also apply to DPDK and TRex tests.
78         elif u"SOAK" in tags:
79             test_type = u"soak"
80         elif u"NDRPDR" in tags:
81             test_type = u"ndrpdr"
82         elif u"MRR" in tags:
83             test_type = u"mrr"
84         else:
85             raise RuntimeError(f"Unable to infer test type from tags: {tags}")
86         return test_type
87
88     def export_pending_data(self):
89         """Write the accumulated data to disk.
90
91         Create missing directories.
92         Reset both file path and data to avoid writing multiple times.
93
94         Functions which finalize content for given file are calling this,
95         so make sure each test and non-empty suite setup or teardown
96         is calling this as their last keyword.
97
98         If no file path is set, do not write anything,
99         as that is the failsafe behavior when caller from unexpected place.
100         Aso do not write anything when EXPORT_JSON constant is false.
101
102         Regardless of whether data was written, it is cleared.
103         """
104         if not Constants.EXPORT_JSON or not self.file_path:
105             self.data = None
106             self.file_path = None
107             return
108         new_file_path = write_output(self.file_path, self.data)
109         # Data is going to be cleared (as a sign that export succeeded),
110         # so this is the last chance to detect if it was for a test case.
111         is_testcase = u"result" in self.data
112         self.data = None
113         # Validation for output goes here when ready.
114         self.file_path = None
115         if is_testcase:
116             validate(new_file_path, self.validators[u"tc_info"])
117
118     def warn_on_bad_export(self):
119         """If bad state is detected, log a warning and clean up state."""
120         if self.file_path is not None or self.data is not None:
121             logger.warn(f"Previous export not clean, path {self.file_path}")
122             self.data = None
123             self.file_path = None
124
125     def start_suite_setup_export(self):
126         """Set new file path, initialize data for the suite setup.
127
128         This has to be called explicitly at start of suite setup,
129         otherwise Robot likes to postpone initialization
130         until first call by a data-adding keyword.
131
132         File path is set based on suite.
133         """
134         self.warn_on_bad_export()
135         start_time = datetime.datetime.utcnow().strftime(
136             u"%Y-%m-%dT%H:%M:%S.%fZ"
137         )
138         suite_name = BuiltIn().get_variable_value(u"\\${SUITE_NAME}")
139         suite_id = suite_name.lower().replace(u" ", u"_")
140         suite_path_part = os.path.join(*suite_id.split(u"."))
141         output_dir = self.output_dir
142         self.file_path = os.path.join(
143             output_dir, suite_path_part, u"setup.info.json"
144         )
145         self.data = dict()
146         self.data[u"version"] = Constants.MODEL_VERSION
147         self.data[u"start_time"] = start_time
148         self.data[u"suite_name"] = suite_name
149         self.data[u"suite_documentation"] = BuiltIn().get_variable_value(
150             u"\\${SUITE_DOCUMENTATION}"
151         )
152         # "end_time" and "duration" are added on flush.
153         self.data[u"hosts"] = set()
154         self.data[u"telemetry"] = list()
155
156     def start_test_export(self):
157         """Set new file path, initialize data to minimal tree for the test case.
158
159         It is assumed Robot variables DUT_TYPE and DUT_VERSION
160         are already set (in suite setup) to correct values.
161
162         This function has to be called explicitly at the start of test setup,
163         otherwise Robot likes to postpone initialization
164         until first call by a data-adding keyword.
165
166         File path is set based on suite and test.
167         """
168         self.warn_on_bad_export()
169         start_time = datetime.datetime.utcnow().strftime(
170             u"%Y-%m-%dT%H:%M:%S.%fZ"
171         )
172         suite_name = BuiltIn().get_variable_value(u"\\${SUITE_NAME}")
173         suite_id = suite_name.lower().replace(u" ", u"_")
174         suite_path_part = os.path.join(*suite_id.split(u"."))
175         test_name = BuiltIn().get_variable_value(u"\\${TEST_NAME}")
176         self.file_path = os.path.join(
177             self.output_dir, suite_path_part,
178             test_name.lower().replace(u" ", u"_") + u".info.json"
179         )
180         self.data = dict()
181         self.data[u"version"] = Constants.MODEL_VERSION
182         self.data[u"start_time"] = start_time
183         self.data[u"suite_name"] = suite_name
184         self.data[u"test_name"] = test_name
185         test_doc = BuiltIn().get_variable_value(u"\\${TEST_DOCUMENTATION}", u"")
186         self.data[u"test_documentation"] = test_doc
187         # "test_type" is added on flush.
188         # "tags" is detected and added on flush.
189         # "end_time" and "duration" is added on flush.
190         # Robot status and message are added on flush.
191         self.data[u"result"] = dict(type=u"unknown")
192         self.data[u"hosts"] = BuiltIn().get_variable_value(u"\\${hosts}")
193         self.data[u"telemetry"] = list()
194         export_dut_type_and_version()
195         export_tg_type_and_version()
196
197     def start_suite_teardown_export(self):
198         """Set new file path, initialize data for the suite teardown.
199
200         This has to be called explicitly at start of suite teardown,
201         otherwise Robot likes to postpone initialization
202         until first call by a data-adding keyword.
203
204         File path is set based on suite.
205         """
206         self.warn_on_bad_export()
207         start_time = datetime.datetime.utcnow().strftime(
208             u"%Y-%m-%dT%H:%M:%S.%fZ"
209         )
210         suite_name = BuiltIn().get_variable_value(u"\\${SUITE_NAME}")
211         suite_id = suite_name.lower().replace(u" ", u"_")
212         suite_path_part = os.path.join(*suite_id.split(u"."))
213         self.file_path = os.path.join(
214             self.output_dir, suite_path_part, u"teardown.info.json"
215         )
216         self.data = dict()
217         self.data[u"version"] = Constants.MODEL_VERSION
218         self.data[u"start_time"] = start_time
219         self.data[u"suite_name"] = suite_name
220         # "end_time" and "duration" is added on flush.
221         self.data[u"hosts"] = BuiltIn().get_variable_value(u"\\${hosts}")
222         self.data[u"telemetry"] = list()
223
224     def finalize_suite_setup_export(self):
225         """Add the missing fields to data. Do not write yet.
226
227         Should be run at the end of suite setup.
228         The write is done at next start (or at the end of global teardown).
229         """
230         end_time = datetime.datetime.utcnow().strftime(u"%Y-%m-%dT%H:%M:%S.%fZ")
231         self.data[u"hosts"] = BuiltIn().get_variable_value(u"\\${hosts}")
232         self.data[u"end_time"] = end_time
233         self.export_pending_data()
234
235     def finalize_test_export(self):
236         """Add the missing fields to data. Do not write yet.
237
238         Should be at the end of test teardown, as the implementation
239         reads various Robot variables, some of them only available at teardown.
240
241         The write is done at next start (or at the end of global teardown).
242         """
243         end_time = datetime.datetime.utcnow().strftime(u"%Y-%m-%dT%H:%M:%S.%fZ")
244         message = BuiltIn().get_variable_value(u"\\${TEST_MESSAGE}")
245         test_tags = BuiltIn().get_variable_value(u"\\${TEST_TAGS}")
246         self.data[u"end_time"] = end_time
247         start_float = parse(self.data[u"start_time"]).timestamp()
248         end_float = parse(self.data[u"end_time"]).timestamp()
249         self.data[u"duration"] = end_float - start_float
250         self.data[u"tags"] = list(test_tags)
251         self.data[u"message"] = message
252         self.process_passed()
253         self.process_test_name()
254         self.process_results()
255         self.export_pending_data()
256
257     def finalize_suite_teardown_export(self):
258         """Add the missing fields to data. Do not write yet.
259
260         Should be run at the end of suite teardown
261         (but before the explicit write in the global suite teardown).
262         The write is done at next start (or explicitly for global teardown).
263         """
264         end_time = datetime.datetime.utcnow().strftime(u"%Y-%m-%dT%H:%M:%S.%fZ")
265         self.data[u"end_time"] = end_time
266         self.export_pending_data()
267
268     def process_test_name(self):
269         """Replace raw test name with short and long test name and set
270         test_type.
271
272         Perform in-place edits on the data dictionary.
273         Remove raw suite_name and test_name, they are not published.
274         Return early if the data is not for test case.
275         Insert test ID and long and short test name into the data.
276         Besides suite_name and test_name, also test tags are read.
277
278         Short test name is basically a suite tag, but with NIC driver prefix,
279         if the NIC driver used is not the default one (drv_vfio_pci for VPP
280         tests).
281
282         Long test name has the following form:
283         {nic_short_name}-{frame_size}-{threads_and_cores}-{suite_part}
284         Lookup in test tags is needed to get the threads value.
285         The threads_and_cores part may be empty, e.g. for TRex tests.
286
287         Test ID has form {suite_name}.{test_name} where the two names come from
288         Robot variables, converted to lower case and spaces replaces by
289         undescores.
290
291         Test type is set in an internal function.
292
293         :raises RuntimeError: If the data does not contain expected values.
294         """
295         suite_part = self.data.pop(u"suite_name").lower().replace(u" ", u"_")
296         if u"test_name" not in self.data:
297             # There will be no test_id, provide suite_id instead.
298             self.data[u"suite_id"] = suite_part
299             return
300         test_part = self.data.pop(u"test_name").lower().replace(u" ", u"_")
301         self.data[u"test_id"] = f"{suite_part}.{test_part}"
302         tags = self.data[u"tags"]
303         # Test name does not contain thread count.
304         subparts = test_part.split(u"c-", 1)
305         if len(subparts) < 2 or subparts[0][-2:-1] != u"-":
306             # Physical core count not detected, assume it is a TRex test.
307             if u"--" not in test_part:
308                 raise RuntimeError(f"Cores not found for {subparts}")
309             short_name = test_part.split(u"--", 1)[1]
310         else:
311             short_name = subparts[1]
312             # Add threads to test_part.
313             core_part = subparts[0][-1] + u"c"
314             for tag in tags:
315                 tag = tag.lower()
316                 if len(tag) == 4 and core_part == tag[2:] and tag[1] == u"t":
317                     test_part = test_part.replace(f"-{core_part}-", f"-{tag}-")
318                     break
319             else:
320                 raise RuntimeError(
321                     f"Threads not found for {test_part} tags {tags}"
322                 )
323         # For long name we need NIC model, which is only in suite name.
324         last_suite_part = suite_part.split(u".")[-1]
325         # Short name happens to be the suffix we want to ignore.
326         prefix_part = last_suite_part.split(short_name)[0]
327         # Also remove the trailing dash.
328         prefix_part = prefix_part[:-1]
329         # Throw away possible link prefix such as "1n1l-".
330         nic_code = prefix_part.split(u"-", 1)[-1]
331         nic_short = Constants.NIC_CODE_TO_SHORT_NAME[nic_code]
332         long_name = f"{nic_short}-{test_part}"
333         # Set test type.
334         test_type = self._detect_test_type()
335         self.data[u"test_type"] = test_type
336         # Remove trailing test type from names (if present).
337         short_name = short_name.split(f"-{test_type}")[0]
338         long_name = long_name.split(f"-{test_type}")[0]
339         # Store names.
340         self.data[u"test_name_short"] = short_name
341         self.data[u"test_name_long"] = long_name
342
343     def process_passed(self):
344         """Process the test status information as boolean.
345
346         Boolean is used to make post processing more efficient.
347         In case the test status is PASS, we will truncate the test message.
348         """
349         status = BuiltIn().get_variable_value(u"\\${TEST_STATUS}")
350         if status is not None:
351             self.data[u"passed"] = (status == u"PASS")
352             if self.data[u"passed"]:
353                 # Also truncate success test messages.
354                 self.data[u"message"] = u""
355
356     def process_results(self):
357         """Process measured results.
358
359         Results are used to avoid future post processing, making it more
360         efficient to consume.
361         """
362         if self.data["telemetry"]:
363             telemetry_encode = "\n".join(self.data["telemetry"]).encode()
364             telemetry_compress = compress(telemetry_encode, level=9)
365             telemetry_base64 = b2a_base64(telemetry_compress, newline=False)
366             self.data["telemetry"] = [telemetry_base64.decode()]
367         if u"result" not in self.data:
368             return
369         result_node = self.data[u"result"]
370         result_type = result_node[u"type"]
371         if result_type == u"unknown":
372             # Device or something else not supported.
373             return
374
375         # Compute avg and stdev for mrr.
376         if result_type == u"mrr":
377             rate_node = result_node[u"receive_rate"][u"rate"]
378             stats = AvgStdevStats.for_runs(rate_node[u"values"])
379             rate_node[u"avg"] = stats.avg
380             rate_node[u"stdev"] = stats.stdev
381             return
382
383         # Multiple processing steps for ndrpdr.
384         if result_type != u"ndrpdr":
385             return
386         # Filter out invalid latencies.
387         for which_key in (u"latency_forward", u"latency_reverse"):
388             if which_key not in result_node:
389                 # Probably just an unidir test.
390                 continue
391             for load in (u"pdr_0", u"pdr_10", u"pdr_50", u"pdr_90"):
392                 if result_node[which_key][load][u"max"] <= 0:
393                     # One invalid number is enough to remove all loads.
394                     break
395             else:
396                 # No break means all numbers are ok, nothing to do here.
397                 continue
398             # Break happened, something is invalid, remove all loads.
399             result_node.pop(which_key)
400         return